diff --git a/AGENTS.md b/AGENTS.md index d5ef51a77..f3e6f51f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,6 @@ For main module headers (e.g., `yup_graphics.h`), include this declaration block description: Brief module description website: https://github.com/kunitoki/yup license: ISC - minimumCppStandard: 17 dependencies: yup_graphics [other_dependencies] searchpaths: native @@ -337,9 +336,9 @@ yup::Result performOperation() yup::ResultValue maybeGetInteger() { if (preconditionFailed) - return yup::ResultValue::fail ("Precondition not met"); + return yup::makeResultValueFail ("Precondition not met"); - return 1; + return 1; // or yup::makeResultValueOk (1) } // Use assertions for programming errors diff --git a/CLAUDE.md b/CLAUDE.md index d5ef51a77..f3e6f51f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,6 @@ For main module headers (e.g., `yup_graphics.h`), include this declaration block description: Brief module description website: https://github.com/kunitoki/yup license: ISC - minimumCppStandard: 17 dependencies: yup_graphics [other_dependencies] searchpaths: native @@ -337,9 +336,9 @@ yup::Result performOperation() yup::ResultValue maybeGetInteger() { if (preconditionFailed) - return yup::ResultValue::fail ("Precondition not met"); + return yup::makeResultValueFail ("Precondition not met"); - return 1; + return 1; // or yup::makeResultValueOk (1) } // Use assertions for programming errors diff --git a/CMakeLists.txt b/CMakeLists.txt index 683deaa1c..bedc04bab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,7 @@ if (YUP_BUILD_EXAMPLES) add_subdirectory (examples/graphics) if (YUP_PLATFORM_DESKTOP) add_subdirectory (examples/plugin) + add_subdirectory (examples/audiograph) endif() endif() diff --git a/README.md b/README.md index af37eada0..acfc0f07b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,15 @@ +
+ + +
+ +
+ +
+
@@ -113,6 +122,14 @@ YUP brings a suite of powerful features, including: | **Linux** | :construction: | :construction: | | | | | | +## Supported Plugin Hosting Formats +| | **CLAP** | **VST3** | **VST2** | **AUv3** | **AUv2** | **AAX** | **LV2** | +|--------------------------|:------------------:|:------------------:|:------------------:|:------------------:|:-------------------------:|:---------------------:|:---------------------:| +| **Windows** | :construction: | :construction: | | | | | | +| **macOS** | :construction: | :construction: | | | :white_check_mark: | | | +| **Linux** | :construction: | :construction: | | | | | | + + ## Supported Sound Formats | | **Wav** | **Wav64** | **Mp3** | **OGG** | **Flac** | **Opus** | **AAC** | **WMF** | @@ -130,6 +147,7 @@ YUP brings a suite of powerful features, including: | **iOS** (enc) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | | **iOS** (dec) | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | + ## Prerequisites Before building, ensure you have a: - C++20-compliant compiler diff --git a/cmake/yup_audio_plugin.cmake b/cmake/yup_audio_plugin.cmake index cf2f05579..d5f7ce766 100644 --- a/cmake/yup_audio_plugin.cmake +++ b/cmake/yup_audio_plugin.cmake @@ -91,14 +91,19 @@ function (yup_audio_plugin) _yup_fetch_sdl2() list (APPEND additional_libraries sdl2::sdl2) + _yup_target_list_contains ("${YUP_ARG_MODULES}" yup_audio_plugin_host has_audio_plugin_host) + if (has_audio_plugin_host) + _yup_collect_audio_plugin_host_dependencies ("${YUP_ARG_DEFINITIONS}" audio_plugin_host_libraries) + list (APPEND additional_libraries ${audio_plugin_host_libraries}) + if (audio_plugin_host_libraries) + target_link_libraries (${target_name}_shared INTERFACE + ${audio_plugin_host_libraries}) + endif() + endif() + # ==== Fetch clap SDK and build clap target if (YUP_ARG_PLUGIN_CREATE_CLAP) - _yup_message (STATUS "Fetching CLAP SDK") - _yup_fetchcontent_declare (clap - GIT_REPOSITORY https://github.com/free-audio/clap.git - GIT_TAG main) - FetchContent_MakeAvailable (clap) - set_target_properties (clap-tests PROPERTIES FOLDER "Tests") + _yup_fetch_clap() _yup_message (STATUS "Setting up CLAP plugin client") _yup_module_setup_plugin_client ( @@ -144,22 +149,7 @@ function (yup_audio_plugin) # ==== Fetch vst3 SDK and build vst3 target if (YUP_ARG_PLUGIN_CREATE_VST3) - _yup_message (STATUS "Fetching VST3 SDK") - set (SMTG_CREATE_MODULE_INFO OFF) - set (SMTG_ADD_VST3_UTILITIES OFF) - set (SMTG_ENABLE_VST3_HOSTING_EXAMPLES OFF) - set (SMTG_ENABLE_VST3_PLUGIN_EXAMPLES OFF) - set (SMTG_ENABLE_VSTGUI_SUPPORT OFF) - set (SMTG_CREATE_PLUGIN_LINK OFF) - if (NOT YUP_PLATFORM_MAC OR XCODE) - set (SMTG_RUN_VST_VALIDATOR ON) - else() - set (SMTG_RUN_VST_VALIDATOR OFF) - endif() - _yup_fetchcontent_declare (vst3sdk - GIT_REPOSITORY https://github.com/steinbergmedia/vst3sdk.git - GIT_TAG master) - FetchContent_MakeAvailable (vst3sdk) + _yup_fetch_vst3sdk() _yup_message (STATUS "Setting up VST3 plugin client") smtg_enable_vst3_sdk() diff --git a/cmake/yup_dependencies.cmake b/cmake/yup_dependencies.cmake index 2fa317996..c2e74f493 100644 --- a/cmake/yup_dependencies.cmake +++ b/cmake/yup_dependencies.cmake @@ -77,6 +77,157 @@ endfunction() #============================================================================== +function (_yup_fetch_clap) + if (NOT TARGET clap) + _yup_message (STATUS "Fetching CLAP SDK") + _yup_fetchcontent_declare (clap + GIT_REPOSITORY https://github.com/free-audio/clap.git + GIT_TAG main) + + FetchContent_MakeAvailable (clap) + endif() + + if (TARGET clap-tests) + set_target_properties (clap-tests PROPERTIES FOLDER "Tests") + endif() +endfunction() + +#============================================================================== + +function (_yup_fetch_vst3sdk) + if (NOT TARGET sdk) + _yup_message (STATUS "Fetching VST3 SDK") + + set (SMTG_CREATE_MODULE_INFO OFF) + set (SMTG_ADD_VST3_UTILITIES OFF) + set (SMTG_ENABLE_VST3_HOSTING_EXAMPLES OFF) + set (SMTG_ENABLE_VST3_PLUGIN_EXAMPLES OFF) + set (SMTG_ENABLE_VSTGUI_SUPPORT OFF) + set (SMTG_CREATE_PLUGIN_LINK OFF) + if (NOT YUP_PLATFORM_MAC OR XCODE) + set (SMTG_RUN_VST_VALIDATOR ON) + else() + set (SMTG_RUN_VST_VALIDATOR OFF) + endif() + + _yup_fetchcontent_declare (vst3sdk + GIT_REPOSITORY https://github.com/steinbergmedia/vst3sdk.git + GIT_TAG master) + + FetchContent_MakeAvailable (vst3sdk) + endif() + + if (NOT TARGET yup_audio_plugin_host_vst3sdk) + add_library (yup_audio_plugin_host_vst3sdk INTERFACE) + target_link_libraries (yup_audio_plugin_host_vst3sdk INTERFACE sdk) + + set (vst3sdk_source_dir "") + if (DEFINED vst3sdk_SOURCE_DIR) + set (vst3sdk_source_dir "${vst3sdk_SOURCE_DIR}") + elseif (TARGET sdk) + get_target_property (vst3sdk_source_dir sdk SOURCE_DIR) + endif() + + set (vst3sdk_memorystream_source "${vst3sdk_source_dir}/public.sdk/source/common/memorystream.cpp") + if (vst3sdk_source_dir AND EXISTS "${vst3sdk_memorystream_source}") + target_sources (yup_audio_plugin_host_vst3sdk INTERFACE "${vst3sdk_memorystream_source}") + endif() + + set (vst3sdk_parameterchanges_source "${vst3sdk_source_dir}/public.sdk/source/vst/hosting/parameterchanges.cpp") + if (vst3sdk_source_dir AND EXISTS "${vst3sdk_parameterchanges_source}") + target_sources (yup_audio_plugin_host_vst3sdk INTERFACE "${vst3sdk_parameterchanges_source}") + endif() + + set (vst3sdk_eventlist_source "${vst3sdk_source_dir}/public.sdk/source/vst/hosting/eventlist.cpp") + if (vst3sdk_source_dir AND EXISTS "${vst3sdk_eventlist_source}") + target_sources (yup_audio_plugin_host_vst3sdk INTERFACE "${vst3sdk_eventlist_source}") + endif() + endif() +endfunction() + +#============================================================================== + +function (_yup_target_list_contains target_list target_name output_variable) + foreach (target IN LISTS target_list) + if ("${target}" STREQUAL "${target_name}" OR "${target}" STREQUAL "yup::${target_name}") + set (${output_variable} ON PARENT_SCOPE) + return() + endif() + + if (TARGET "${target}") + get_target_property (aliased_target "${target}" ALIASED_TARGET) + if ("${aliased_target}" STREQUAL "${target_name}") + set (${output_variable} ON PARENT_SCOPE) + return() + endif() + endif() + endforeach() + + set (${output_variable} OFF PARENT_SCOPE) +endfunction() + +#============================================================================== + +function (_yup_definitions_enable definitions definition_name output_variable) + set (enabled OFF) + + foreach (definition IN LISTS definitions) + string (REGEX REPLACE "^-D" "" normalized_definition "${definition}") + + if (normalized_definition MATCHES "^${definition_name}($|=)") + set (enabled ON) + + if (normalized_definition MATCHES "^${definition_name}=") + string (REGEX REPLACE "^${definition_name}=(.*)$" "\\1" definition_value "${normalized_definition}") + string (STRIP "${definition_value}" definition_value) + string (REGEX REPLACE "^\"(.*)\"$" "\\1" definition_value "${definition_value}") + string (REGEX REPLACE "^'(.*)'$" "\\1" definition_value "${definition_value}") + string (TOUPPER "${definition_value}" definition_value) + + if ("${definition_value}" STREQUAL "0" + OR "${definition_value}" STREQUAL "OFF" + OR "${definition_value}" STREQUAL "FALSE" + OR "${definition_value}" STREQUAL "NO") + set (enabled OFF) + endif() + endif() + endif() + endforeach() + + set (${output_variable} "${enabled}" PARENT_SCOPE) +endfunction() + +#============================================================================== + +function (_yup_collect_audio_plugin_host_dependencies definitions output_variable) + set (dependencies "") + + _yup_definitions_enable ("${definitions}" YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP enable_clap) + if (enable_clap) + _yup_fetch_clap() + list (APPEND dependencies clap) + endif() + + _yup_definitions_enable ("${definitions}" YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3 enable_vst3) + if (enable_vst3) + _yup_fetch_vst3sdk() + list (APPEND dependencies yup_audio_plugin_host_vst3sdk) + endif() + + _yup_definitions_enable ("${definitions}" YUP_AUDIO_PLUGIN_HOST_ENABLE_AU enable_au) + if (enable_au AND YUP_PLATFORM_MAC) + list (APPEND dependencies + "-framework AudioUnit" + "-framework AudioToolbox" + "-framework CoreAudio" + "-framework CoreFoundation") + endif() + + set (${output_variable} "${dependencies}" PARENT_SCOPE) +endfunction() + +#============================================================================== + function (_yup_fetch_perfetto) if (TARGET perfetto::perfetto) return() diff --git a/cmake/yup_modules.cmake b/cmake/yup_modules.cmake index 7228ac17a..c9be8ac3f 100644 --- a/cmake/yup_modules.cmake +++ b/cmake/yup_modules.cmake @@ -802,6 +802,11 @@ function (yup_add_module module_path modules_definitions module_group) list (APPEND module_defines ${module_definition}) endforeach() + if ("${module_name}" STREQUAL "yup_audio_plugin_host") + _yup_collect_audio_plugin_host_dependencies ("${module_defines}" audio_plugin_host_dependencies) + list (APPEND module_dependencies ${audio_plugin_host_dependencies}) + endif() + # ==== Prepare include paths get_filename_component (module_include_path ${module_path} DIRECTORY) list (APPEND module_include_paths "${module_include_path}" "${module_path}") diff --git a/cmake/yup_standalone.cmake b/cmake/yup_standalone.cmake index fd0abb187..135f8d14f 100644 --- a/cmake/yup_standalone.cmake +++ b/cmake/yup_standalone.cmake @@ -102,6 +102,14 @@ function (yup_standalone_app) list (APPEND additional_libraries perfetto::perfetto) endif() + if (YUP_PLATFORM_DESKTOP) + _yup_target_list_contains ("${YUP_ARG_MODULES}" yup_audio_plugin_host has_audio_plugin_host) + if (has_audio_plugin_host) + _yup_collect_audio_plugin_host_dependencies ("${YUP_ARG_DEFINITIONS}" audio_plugin_host_libraries) + list (APPEND additional_libraries ${audio_plugin_host_libraries}) + endif() + endif() + # ==== Prepare executable set (executable_options "") if (NOT "${target_console}") diff --git a/docs/images/yup_audio_graph.png b/docs/images/yup_audio_graph.png new file mode 100644 index 000000000..39a812630 Binary files /dev/null and b/docs/images/yup_audio_graph.png differ diff --git a/docs/images/yup_audio_host.png b/docs/images/yup_audio_host.png new file mode 100644 index 000000000..5eb198934 Binary files /dev/null and b/docs/images/yup_audio_host.png differ diff --git a/docs/images/yup_audio_waveform.png b/docs/images/yup_audio_waveform.png new file mode 100644 index 000000000..87ed5dff4 Binary files /dev/null and b/docs/images/yup_audio_waveform.png differ diff --git a/examples/audiograph/CMakeLists.txt b/examples/audiograph/CMakeLists.txt new file mode 100644 index 000000000..dc8a0dde2 --- /dev/null +++ b/examples/audiograph/CMakeLists.txt @@ -0,0 +1,55 @@ +# ============================================================================== +# +# This file is part of the YUP library. +# Copyright (c) 2026 - kunitoki@gmail.com +# +# YUP is an open source library subject to open-source licensing. +# +# The code included in this file is provided under the terms of the ISC license +# http://www.isc.org/downloads/software-support-policy/isc-license. Permission +# to use, copy, modify, and/or distribute this software for any purpose with or +# without fee is hereby granted provided that the above copyright notice and +# this permission notice appear in all copies. +# +# YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER +# EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE +# DISCLAIMED. +# +# ============================================================================== + +cmake_minimum_required (VERSION 3.31) + +set (target_name example_audiograph) +set (target_version "1.0.0") + +project (${target_name} VERSION ${target_version}) + +yup_standalone_app ( + TARGET_NAME ${target_name} + TARGET_VERSION ${target_version} + TARGET_IDE_GROUP "Examples" + TARGET_APP_ID "org.yup.${target_name}" + TARGET_APP_NAMESPACE "org.yup" + DEFINITIONS + YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP=1 + YUP_AUDIO_PLUGIN_HOST_ENABLE_AU=1 + YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3=1 + MODULES + yup::yup_core + yup::yup_audio_basics + yup::yup_audio_devices + yup::yup_dsp + yup::yup_events + yup::yup_graphics + yup::yup_gui + yup::yup_audio_formats + yup::yup_audio_gui + yup::yup_audio_processors + yup::yup_audio_graph + yup::yup_audio_plugin_host) + +file (GLOB_RECURSE sources + "${CMAKE_CURRENT_LIST_DIR}/source/*.cpp" + "${CMAKE_CURRENT_LIST_DIR}/source/*.h") +source_group (TREE ${CMAKE_CURRENT_LIST_DIR}/ FILES ${sources}) +target_sources (${target_name} PRIVATE ${sources}) diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h new file mode 100644 index 000000000..7f0647969 --- /dev/null +++ b/examples/audiograph/source/AudioGraphApp.h @@ -0,0 +1,734 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include +#include +#include + +//============================================================================== + +class SoundCardInputNodeView final : public yup::AudioGraphNodeView +{ +public: + SoundCardInputNodeView() + : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + { + } + + yup::String getNodeTitle() const override { return "INPUT"; } + + yup::String getNodeSubtitle() const override { return "sound card"; } + + int getNumInputPorts() const override { return 0; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 150; } + + yup::Color getNodeColor() const override { return yup::Color (0xfff97316); } + + PortInfo getOutputPortInfo (int) const override + { + return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; + } +}; + +class SoundCardOutputNodeView final : public yup::AudioGraphNodeView +{ +public: + SoundCardOutputNodeView() + : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + { + } + + yup::String getNodeTitle() const override { return "OUTPUT"; } + + yup::String getNodeSubtitle() const override { return "sound card"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 0; } + + int getPreferredWidth() const override { return 150; } + + yup::Color getNodeColor() const override { return yup::Color (0xff06b6d4); } + + PortInfo getInputPortInfo (int) const override + { + return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; + } +}; + +//============================================================================== + +class AudioGraphApp final + : public yup::Component + , public yup::AudioIODeviceCallback + , public yup::AudioGraphComponent::Listener +{ +public: + AudioGraphApp() + { + deviceManager.initialiseWithDefaultDevices (2, 2); + + graph = std::make_shared(); + nodeRegistry.registerInternalNodes(); + +#if YUP_DESKTOP + scanner = std::make_unique(); + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3 + scanner->addFormat (std::make_unique()); +#endif +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP + scanner->addFormat (std::make_unique()); +#endif +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_AU && YUP_MAC + scanner->addFormat (std::make_unique()); +#endif + + nodeRegistry.registerPluginFormats (scanner.get(), makeHostContext()); +#endif + + graph->setNodeFactory (nodeRegistry.makeProcessorFactory()); + + graphComponent = std::make_unique (graph); + graphComponent->onCanvasContextMenu = [this] (yup::Point canvasPos) + { + showAddNodeMenu (canvasPos); + }; + graphComponent->onNodeContextMenu = [this] (yup::AudioGraphNodeID id, yup::Point) + { + removeNode (id); + }; + graphComponent->onNodeDoubleClicked = [this] (yup::AudioGraphNodeID id) + { + openPluginEditor (id); + }; + graphComponent->addListener (this); + addAndMakeVisible (*graphComponent); + + setupToolbar(); + resetToEmptyGraph(); + +#if YUP_DESKTOP + startPluginScan(); +#endif + } + + ~AudioGraphApp() override + { +#if YUP_DESKTOP + scanLifetime->store (false); +#endif + + closePluginEditor(); + + if (audioCallbackRegistered) + { + deviceManager.removeAudioCallback (this); + audioCallbackRegistered = false; + } + + deviceManager.closeAudioDevice(); + } + + void resized() override + { + auto bounds = getLocalBounds(); + auto toolbar = bounds.removeFromTop (36); + + newButton.setBounds (toolbar.removeFromLeft (70).reduced (4, 4)); + openButton.setBounds (toolbar.removeFromLeft (70).reduced (4, 4)); + saveButton.setBounds (toolbar.removeFromLeft (70).reduced (4, 4)); + toolbar.removeFromLeft (8); + scanButton.setBounds (toolbar.removeFromLeft (100).reduced (4, 4)); + statusLabel.setBounds (toolbar.reduced (4, 4)); + + graphComponent->setBounds (bounds); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (yup::Color (0xff0d1117)); + g.fillAll(); + + g.setFillColor (yup::Color (0xff161b22)); + g.fillRect (getLocalBounds().removeFromTop (36).to()); + } + + void visibilityChanged() override + { + if (isVisible()) + { + deviceManager.addAudioCallback (this); + audioCallbackRegistered = true; + } + else if (audioCallbackRegistered) + { + deviceManager.removeAudioCallback (this); + audioCallbackRegistered = false; + } + } + + //============================================================================== + // AudioIODeviceCallback + + void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, + int numInputChannels, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const yup::AudioIODeviceCallbackContext&) override + { + for (int ch = 0; ch < numOutputChannels; ++ch) + yup::FloatVectorOperations::clear (outputChannelData[ch], numSamples); + + if (graph == nullptr || numOutputChannels <= 0 || numSamples <= 0) + return; + + yup::AudioBuffer outputBuffer (outputChannelData, numOutputChannels, numSamples); + + for (int ch = 0; ch < yup::jmin (numInputChannels, numOutputChannels); ++ch) + { + if (inputChannelData != nullptr && inputChannelData[ch] != nullptr) + yup::FloatVectorOperations::copy (outputChannelData[ch], inputChannelData[ch], numSamples); + } + + yup::MidiBuffer midi; + graph->processBlock (outputBuffer, midi); + } + + void audioDeviceAboutToStart (yup::AudioIODevice* device) override + { + if (graph == nullptr || device == nullptr) + return; + +#if YUP_DESKTOP + yup::AudioPluginHostContext ctx; + ctx.sampleRate = static_cast (device->getCurrentSampleRate()); + ctx.maxBlockSize = device->getCurrentBufferSizeSamples(); + nodeRegistry.setPluginScanner (scanner.get(), ctx); +#endif + + graph->prepareToPlay (static_cast (device->getCurrentSampleRate()), + device->getCurrentBufferSizeSamples()); + } + + void audioDeviceStopped() override + { + if (graph != nullptr) + graph->releaseResources(); + } + + //============================================================================== + // AudioGraphComponent::Listener + + void nodeViewMoved (yup::AudioGraphNodeID nodeID, yup::Point newCanvasPos) override + { + graph->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + } + +private: + //============================================================================== + + void setupToolbar() + { + newButton.setButtonText ("New"); + newButton.onClick = [this] + { + resetToEmptyGraph(); + statusLabel.setText ("New graph.", yup::dontSendNotification); + }; + addAndMakeVisible (newButton); + + openButton.setButtonText ("Open"); + openButton.onClick = [this] + { + openGraph(); + }; + addAndMakeVisible (openButton); + + saveButton.setButtonText ("Save"); + saveButton.onClick = [this] + { + saveGraph(); + }; + addAndMakeVisible (saveButton); + + scanButton.setButtonText ("Scan Plugins"); + scanButton.onClick = [this] + { + startPluginScan(); + }; + addAndMakeVisible (scanButton); + + statusLabel.setText ("Ready.", yup::dontSendNotification); + addAndMakeVisible (statusLabel); + } + + //============================================================================== + + void resetToEmptyGraph() + { + closePluginEditor(); + + for (auto& [nodeID, _] : loadedNodes) + graphComponent->removeNodeView (nodeID); + + loadedNodes.clear(); + graph->clear(); + graph->commitChanges(); + currentFilePath = yup::File(); + + graphComponent->setGraphInputView (std::make_unique(), { 40.0f, 200.0f }); + graphComponent->setGraphOutputView (std::make_unique(), { 760.0f, 200.0f }); + graphComponent->zoomToFitNodes(); + } + + //============================================================================== + + void openGraph() + { + fileChooser = yup::FileChooser::create ( + "Open Audio Graph", + currentFilePath.existsAsFile() ? currentFilePath.getParentDirectory() + : yup::File::getSpecialLocation (yup::File::userDocumentsDirectory), + "*.yug"); + + fileChooser->browseForFileToOpen ([this] (bool success, const yup::Array& results) + { + if (! success || results.isEmpty()) + return; + + const auto& file = results[0]; + + yup::MemoryBlock mb; + if (! file.loadFileAsData (mb)) + { + statusLabel.setText ("Failed to read file.", yup::dontSendNotification); + return; + } + + loadGraphFromMemory (mb, file); + }); + } + + void saveGraph() + { + if (currentFilePath.existsAsFile()) + { + saveGraphToFile (currentFilePath); + return; + } + + fileChooser = yup::FileChooser::create ( + "Save Audio Graph", + yup::File::getSpecialLocation (yup::File::userDocumentsDirectory).getChildFile ("Untitled.yug"), + "*.yug"); + + fileChooser->browseForFileToSave ([this] (bool success, const yup::Array& results) + { + if (! success || results.isEmpty()) + return; + + auto file = results[0]; + if (file.getFileExtension().isEmpty()) + file = file.withFileExtension ("yug"); + + saveGraphToFile (file); + }, + true); + } + + void saveGraphToFile (const yup::File& file) + { + yup::MemoryBlock mb; + const auto result = graph->saveStateIntoMemory (mb); + + if (result.failed()) + { + statusLabel.setText ("Save failed: " + result.getErrorMessage(), yup::dontSendNotification); + return; + } + + if (! file.replaceWithData (mb.getData(), mb.getSize())) + { + statusLabel.setText ("Could not write: " + file.getFileName(), yup::dontSendNotification); + return; + } + + currentFilePath = file; + statusLabel.setText ("Saved: " + file.getFileName(), yup::dontSendNotification); + } + + void loadGraphFromMemory (const yup::MemoryBlock& mb, const yup::File& file) + { + closePluginEditor(); + + for (auto& [nodeID, _] : loadedNodes) + graphComponent->removeNodeView (nodeID); + loadedNodes.clear(); + + graph->clear(); + + const auto result = graph->loadStateFromMemory (mb); + if (result.failed()) + { + statusLabel.setText ("Load failed: " + result.getErrorMessage(), yup::dontSendNotification); + graph->commitChanges(); + return; + } + + graph->commitChanges(); + currentFilePath = file; + + for (auto nodeID : graph->getNodeIDs()) + { + auto props = graph->getNodeProperties (nodeID); + if (! props.has_value()) + continue; + + auto* proc = graph->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, props->identifier, proc); + + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), { props->positionX, props->positionY }); + loadedNodes[nodeID] = props->identifier; + } + } + + graphComponent->setGraphInputView (std::make_unique(), { 40.0f, 200.0f }); + graphComponent->setGraphOutputView (std::make_unique(), { 760.0f, 200.0f }); + graphComponent->zoomToFitNodes(); + + statusLabel.setText ("Loaded: " + file.getFileName(), yup::dontSendNotification); + } + + //============================================================================== + + void showAddNodeMenu (yup::Point canvasPos) + { + pendingMenuCanvasPos = canvasPos; + + const auto screenPos = graphComponent->localToScreen (graphComponent->canvasToScreen (canvasPos)); + const auto options = yup::PopupMenu::Options {} + .withFocusComponent (graphComponent.get()) + .withPosition (screenPos, yup::Justification::topLeft); + + auto internalSubMenu = yup::PopupMenu::create(); + internalSubMenu->addItem ("Oscillator", 1); + internalSubMenu->addItem ("Gain", 2); + internalSubMenu->addItem ("Low Pass Filter", 3); + internalSubMenu->addItem ("Sample Player", 4); + + activeMenu = yup::PopupMenu::create (options); + activeMenu->addSubMenu ("Internal Nodes", internalSubMenu); + +#if YUP_DESKTOP + const auto& plugins = nodeRegistry.getDiscoveredPlugins(); + + if (! plugins.empty()) + { + auto pluginSubMenu = yup::PopupMenu::create(); + + for (int i = 0; i < static_cast (plugins.size()); ++i) + { + const auto& desc = plugins[static_cast (i)]; + pluginSubMenu->addItem (desc.name + " [" + formatTypeToString (desc.formatType) + "]", + 100 + i); + } + + activeMenu->addSubMenu ("Plugins", pluginSubMenu); + } + else + { + activeMenu->addItem ("No plugins (click Scan)", -1, false); + } +#endif + + activeMenu->show ([this] (int selectedID) + { + if (selectedID <= 0) + return; + + const yup::String internalIds[] = { + NodeRegistry::oscillatorIdentifier, + NodeRegistry::gainIdentifier, + NodeRegistry::lpfIdentifier, + NodeRegistry::samplePlayerIdentifier + }; + + if (selectedID >= 1 && selectedID <= 4) + { + addInternalNode (internalIds[selectedID - 1], pendingMenuCanvasPos); + } +#if YUP_DESKTOP + else if (selectedID >= 100) + { + const auto& plugins = nodeRegistry.getDiscoveredPlugins(); + + const auto index = static_cast (selectedID - 100); + if (index < plugins.size()) + addPluginNode (plugins[index], pendingMenuCanvasPos); + } +#endif + }); + } + + void addInternalNode (const yup::String& identifier, yup::Point canvasPos) + { + yup::AudioGraphNodeProperties props; + props.identifier = identifier; + props.name = NodeRegistry::identifierToDisplayName (identifier); + props.positionX = canvasPos.getX(); + props.positionY = canvasPos.getY(); + + auto processorResult = nodeRegistry.makeProcessorFactory() (props); + + if (processorResult.failed()) + { + statusLabel.setText ("Failed: " + processorResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + const auto nodeID = graph->addNode (std::move (processorResult).getValue(), props); + if (! nodeID.isValid()) + { + statusLabel.setText ("Failed to add node to graph.", yup::dontSendNotification); + return; + } + + graph->commitChanges(); + + auto* rawProc = graph->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, identifier, rawProc); + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), canvasPos); + loadedNodes[nodeID] = identifier; + } + } + +#if YUP_DESKTOP + void addPluginNode (const yup::AudioPluginDescription& desc, yup::Point canvasPos) + { + auto* format = scanner->getFormatForType (desc.formatType); + if (format == nullptr) + { + statusLabel.setText ("No format backend for: " + desc.name, yup::dontSendNotification); + return; + } + + auto result = format->loadPlugin (desc, makeHostContext()); + if (result.failed()) + { + statusLabel.setText ("Load failed: " + result.getErrorMessage(), yup::dontSendNotification); + return; + } + + yup::AudioGraphNodeProperties props; + props.identifier = NodeRegistry::identifierForDescription (desc); + props.name = desc.name; + props.positionX = canvasPos.getX(); + props.positionY = canvasPos.getY(); + props.creationData = NodeRegistry::descriptionToCreationData (desc); + + const auto nodeID = graph->addNode (std::move (result).getValue(), props); + if (! nodeID.isValid()) + { + statusLabel.setText ("Failed to add plugin to graph.", yup::dontSendNotification); + return; + } + + graph->commitChanges(); + + auto* rawProc = graph->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, props.identifier, rawProc); + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), canvasPos); + loadedNodes[nodeID] = props.identifier; + } + + statusLabel.setText ("Loaded: " + desc.name, yup::dontSendNotification); + } +#endif + + void removeNode (yup::AudioGraphNodeID nodeID) + { + if (activePluginEditorNodeID == nodeID) + { + closePluginEditor(); + } + + graphComponent->removeNodeView (nodeID); + graph->removeNode (nodeID); + graph->commitChanges(); + loadedNodes.erase (nodeID); + } + + //============================================================================== + + void openPluginEditor (yup::AudioGraphNodeID nodeID) + { + if (pluginEditorWindow != nullptr && activePluginEditorNodeID == nodeID) + { + pluginEditorWindow->toFront (true); + return; + } + + auto* proc = graph->getNodeProcessor (nodeID); + if (proc == nullptr || ! proc->hasEditor()) + return; + + auto editor = std::unique_ptr (proc->createEditor()); + if (editor == nullptr) + return; + + closePluginEditor(); + activePluginEditorNodeID = nodeID; + + const auto preferredSize = editor->getPreferredSize(); + + yup::WeakReference weakThis (this); + + pluginEditorWindow = std::make_unique ( + proc->getName(), + std::move (editor), + [weakThis]() + { + if (auto* self = weakThis.get()) + self->closePluginEditor(); + }); + + pluginEditorWindow->centreWithSize (preferredSize); + pluginEditorWindow->setVisible (true); + pluginEditorWindow->toFront (true); + } + + void closePluginEditor() + { + pluginEditorWindow.reset(); + activePluginEditorNodeID = yup::AudioGraphNodeID::invalid(); + } + + //============================================================================== + +#if YUP_DESKTOP + void startPluginScan() + { + if (scanInProgress.exchange (true)) + return; + + statusLabel.setText ("Scanning for plugins...", yup::dontSendNotification); + scanButton.setEnabled (false); + + auto lifetime = scanLifetime; + + scanner->scanDefaultsAsync ([this, lifetime] (yup::AudioPluginScanner::ScanResult result) mutable + { + if (! lifetime->load()) + return; + + yup::MessageManager::callAsync ([this, lifetime, result = std::move (result)]() mutable + { + if (! lifetime->load()) + return; + + nodeRegistry.setDiscoveredPlugins (std::move (result.discovered)); + + const auto count = static_cast (nodeRegistry.getDiscoveredPlugins().size()); + statusLabel.setText ("Found " + yup::String (count) + " plugins.", yup::dontSendNotification); + scanButton.setEnabled (true); + scanInProgress = false; + }); + }); + } + + yup::AudioPluginHostContext makeHostContext() const + { + yup::AudioPluginHostContext ctx; + ctx.sampleRate = 44100.0f; + ctx.maxBlockSize = 512; + + if (auto* dev = deviceManager.getCurrentAudioDevice()) + { + ctx.sampleRate = static_cast (dev->getCurrentSampleRate()); + ctx.maxBlockSize = dev->getCurrentBufferSizeSamples(); + } + + return ctx; + } + + static yup::String formatTypeToString (yup::AudioPluginFormatType type) + { + switch (type) + { + case yup::AudioPluginFormatType::vst3: + return "VST3"; + case yup::AudioPluginFormatType::clap: + return "CLAP"; + case yup::AudioPluginFormatType::audioUnit: + return "AU"; + default: + return "?"; + } + } +#endif + + //============================================================================== + + yup::AudioDeviceManager deviceManager; + std::shared_ptr graph; + std::unique_ptr graphComponent; + NodeRegistry nodeRegistry; + std::map loadedNodes; + + yup::File currentFilePath; + yup::FileChooser::Ptr fileChooser; + + std::unique_ptr pluginEditorWindow; + yup::AudioGraphNodeID activePluginEditorNodeID { yup::AudioGraphNodeID::invalid() }; + + yup::TextButton newButton; + yup::TextButton openButton; + yup::TextButton saveButton; + yup::TextButton scanButton; + yup::Label statusLabel; + + bool audioCallbackRegistered = false; + + yup::PopupMenu::Ptr activeMenu; + yup::Point pendingMenuCanvasPos; + +#if YUP_DESKTOP + std::shared_ptr> scanLifetime { std::make_shared> (true) }; + std::atomic scanInProgress { false }; + std::unique_ptr scanner; +#endif + + YUP_DECLARE_WEAK_REFERENCEABLE (AudioGraphApp) +}; diff --git a/examples/audiograph/source/main.cpp b/examples/audiograph/source/main.cpp new file mode 100644 index 000000000..7f7fc4f24 --- /dev/null +++ b/examples/audiograph/source/main.cpp @@ -0,0 +1,118 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if YUP_DESKTOP +#include +#endif + +#include "nodes/OscillatorNode.h" +#include "nodes/GainNode.h" +#include "nodes/LowPassFilterNode.h" +#include "nodes/SamplePlayerNode.h" +#include "nodes/PluginNodeView.h" +#include "nodes/NodeRegistry.h" +#include "ui/PluginEditorWindow.h" +#include "AudioGraphApp.h" + +//============================================================================== + +class AudioGraphWindow final : public yup::DocumentWindow +{ +public: + AudioGraphWindow() + : yup::DocumentWindow (yup::ComponentNative::Options().withAllowedHighDensityDisplay (true), + yup::Color (0xff0d1117)) + { + setTitle ("YUP Audio Graph"); + + app = std::make_unique(); + addAndMakeVisible (app.get()); + } + + void resized() override + { + app->setBounds (getLocalBounds()); + } + + void keyDown (const yup::KeyPress& keys, const yup::Point&) override + { + if (keys.getKey() == yup::KeyPress::escapeKey) + userTriedToCloseWindow(); + } + + void userTriedToCloseWindow() override + { + yup::YUPApplication::getInstance()->systemRequestedQuit(); + } + +private: + std::unique_ptr app; +}; + +//============================================================================== + +struct Application final : yup::YUPApplication +{ + Application() = default; + + yup::String getApplicationName() override + { + return "YUP Audio Graph"; + } + + yup::String getApplicationVersion() override + { + return "1.0"; + } + + void initialise (const yup::String&) override + { + yup::MessageManager::callAsync ([this] + { + yup::Process::makeForegroundProcess(); + + window = std::make_unique(); + window->centreWithSize ({ 1200, 800 }); + window->setVisible (true); + }); + } + + void shutdown() override + { + window.reset(); + } + +private: + std::unique_ptr window; +}; + +START_YUP_APPLICATION (Application) diff --git a/examples/audiograph/source/nodes/GainNode.h b/examples/audiograph/source/nodes/GainNode.h new file mode 100644 index 000000000..f292456f0 --- /dev/null +++ b/examples/audiograph/source/nodes/GainNode.h @@ -0,0 +1,128 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include + +#include "NodeViewHelpers.h" + +//============================================================================== +class GainProcessor final : public yup::AudioProcessor +{ +public: + GainProcessor() + : AudioProcessor ("Gain", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (yup::AudioBuffer& audioBuffer, yup::MidiBuffer&) override + { + audioBuffer.applyGain (gain.load (std::memory_order_relaxed)); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + yup::Result loadStateFromMemory (const yup::MemoryBlock&) override { return yup::Result::ok(); } + + yup::Result saveStateIntoMemory (yup::MemoryBlock&) override { return yup::Result::ok(); } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + float getGain() const noexcept { return gain.load (std::memory_order_relaxed); } + + void setGain (float newGain) noexcept + { + gain.store (yup::jlimit (0.0f, 1.5f, newGain), std::memory_order_relaxed); + } + +private: + std::atomic gain { 0.75f }; +}; + +//============================================================================== +class GainNodeView final : public yup::AudioGraphNodeView +{ +public: + GainNodeView (yup::AudioGraphNodeID nodeID, GainProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , gainSlider (yup::Slider::LinearBarHorizontal) + { + NodeViewHelpers::configureParameterSlider (gainSlider, getPortKindColor (PortKind::parameter)); + gainSlider.setRange (0.0, 1.5); + gainSlider.setValue (processor.getGain(), yup::dontSendNotification); + gainSlider.onValueChanged = [this] (double value) + { + processor.setGain (static_cast (value)); + repaint(); + }; + addAndMakeVisible (gainSlider); + } + + yup::String getNodeTitle() const override { return "GAIN"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 220; } + + yup::Color getNodeColor() const override { return yup::Color (0xff10b981); } + + yup::String getNodeSubtitle() const override { return yup::String ("x ") + yup::String (processor.getGain(), 2); } + + PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 1; } + + ParameterInfo getParameterInfo (int) const override + { + return { "Gain", yup::String (processor.getGain(), 2), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + } + + void resized() override + { + gainSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); + } + +private: + GainProcessor& processor; + yup::Slider gainSlider; +}; diff --git a/examples/audiograph/source/nodes/LowPassFilterNode.h b/examples/audiograph/source/nodes/LowPassFilterNode.h new file mode 100644 index 000000000..6018acbed --- /dev/null +++ b/examples/audiograph/source/nodes/LowPassFilterNode.h @@ -0,0 +1,151 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include + +#include "NodeViewHelpers.h" + +//============================================================================== +class LowPassFilterProcessor final : public yup::AudioProcessor +{ +public: + LowPassFilterProcessor() + : AudioProcessor ("Low-pass", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + } + + void prepareToPlay (float newSampleRate, int) override + { + sampleRate = newSampleRate; + state[0] = 0.0f; + state[1] = 0.0f; + } + + void releaseResources() override {} + + void processBlock (yup::AudioBuffer& audioBuffer, yup::MidiBuffer&) override + { + const auto currentCutoff = static_cast (cutoff.load (std::memory_order_relaxed)); + const auto alpha = static_cast (1.0 - std::exp (-yup::MathConstants::twoPi * currentCutoff / static_cast (sampleRate))); + + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + { + auto y = state[static_cast (yup::jmin (channel, 1))]; + + for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) + { + y += alpha * (audioBuffer.getSample (channel, sample) - y); + audioBuffer.setSample (channel, sample, y); + } + + state[static_cast (yup::jmin (channel, 1))] = y; + } + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + yup::Result loadStateFromMemory (const yup::MemoryBlock&) override { return yup::Result::ok(); } + + yup::Result saveStateIntoMemory (yup::MemoryBlock&) override { return yup::Result::ok(); } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + double getCutoff() const noexcept { return static_cast (cutoff.load (std::memory_order_relaxed)); } + + void setCutoff (double newCutoff) noexcept + { + cutoff.store (static_cast (yup::jlimit (80.0, 12000.0, newCutoff)), std::memory_order_relaxed); + } + +private: + float sampleRate = 44100.0f; + std::atomic cutoff { 800.0f }; + float state[2] {}; +}; + +//============================================================================== +class LowPassFilterNodeView final : public yup::AudioGraphNodeView +{ +public: + LowPassFilterNodeView (yup::AudioGraphNodeID nodeID, LowPassFilterProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , cutoffSlider (yup::Slider::LinearBarHorizontal) + { + NodeViewHelpers::configureParameterSlider (cutoffSlider, getPortKindColor (PortKind::parameter)); + cutoffSlider.setRange (80.0, 12000.0); + cutoffSlider.setSkewFactorFromMidpoint (1000.0); + cutoffSlider.setValue (processor.getCutoff(), yup::dontSendNotification); + cutoffSlider.onValueChanged = [this] (double value) + { + processor.setCutoff (value); + repaint(); + }; + addAndMakeVisible (cutoffSlider); + } + + yup::String getNodeTitle() const override { return "LPF"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 220; } + + yup::Color getNodeColor() const override { return yup::Color (0xff7c3aed); } + + yup::String getNodeSubtitle() const override { return yup::String (processor.getCutoff(), 0) + " Hz"; } + + PortInfo getInputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 1; } + + ParameterInfo getParameterInfo (int) const override + { + return { "Cutoff", yup::String (processor.getCutoff(), 0), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + } + + void resized() override + { + cutoffSlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); + } + +private: + LowPassFilterProcessor& processor; + yup::Slider cutoffSlider; +}; diff --git a/examples/audiograph/source/nodes/NodeRegistry.h b/examples/audiograph/source/nodes/NodeRegistry.h new file mode 100644 index 000000000..d8db05e23 --- /dev/null +++ b/examples/audiograph/source/nodes/NodeRegistry.h @@ -0,0 +1,423 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include +#include +#include + +//============================================================================== +/** + Maps stable string identifiers to processor and view factories. + + NodeRegistry drives both the AudioGraphProcessor::NodeFactory (used for + graph state reconstruction after loading) and menu-based node creation in + the editor. Register all internal node types via registerInternalNodes() + and, on desktop builds, plugin format entries via registerPluginFormats(). +*/ +class NodeRegistry +{ +public: + //============================================================================== + /** Stable factory key for the built-in oscillator node. */ + static constexpr const char* oscillatorIdentifier = "internal.oscillator"; + + /** Stable factory key for the built-in gain node. */ + static constexpr const char* gainIdentifier = "internal.gain"; + + /** Stable factory key for the built-in low-pass filter node. */ + static constexpr const char* lpfIdentifier = "internal.lpf"; + + /** Stable factory key for the built-in looping sample player node. */ + static constexpr const char* samplePlayerIdentifier = "internal.samplePlayer"; + +#if YUP_DESKTOP + /** Stable factory key for VST3 plugin nodes. */ + static constexpr const char* pluginVst3Identifier = "plugin.vst3"; + + /** Stable factory key for CLAP plugin nodes. */ + static constexpr const char* pluginClapIdentifier = "plugin.clap"; + + /** Stable factory key for Audio Unit plugin nodes. */ + static constexpr const char* pluginAuIdentifier = "plugin.au"; +#endif + + //============================================================================== + using ProcessorFactory = std::function> (const yup::AudioGraphNodeProperties&)>; + using ViewFactory = std::function (yup::AudioGraphNodeID, yup::AudioProcessor*)>; + + //============================================================================== + struct Entry + { + ProcessorFactory createProcessor; + ViewFactory createView; + }; + + //============================================================================== + /** + Registers the built-in internal node types. + + Each entry gets a processor factory and a matching view factory. The view + factory dynamic_casts to the concrete processor type before constructing + the view. + */ + void registerInternalNodes() + { + entries[oscillatorIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + { + auto* osc = dynamic_cast (proc); + if (osc == nullptr) + return nullptr; + + return std::make_unique (nodeID, *osc); + } + }; + + entries[gainIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + { + auto* gain = dynamic_cast (proc); + if (gain == nullptr) + return nullptr; + + return std::make_unique (nodeID, *gain); + } + }; + + entries[lpfIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + { + auto* lpf = dynamic_cast (proc); + if (lpf == nullptr) + return nullptr; + + return std::make_unique (nodeID, *lpf); + } + }; + + entries[samplePlayerIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + { + auto* samplePlayer = dynamic_cast (proc); + if (samplePlayer == nullptr) + return nullptr; + + return std::make_unique (nodeID, *samplePlayer); + } + }; + } + +#if YUP_DESKTOP + //============================================================================== + /** + Stores the plugin scanner and host context, then registers factory entries + for all three plugin format identifiers (VST3, CLAP, AudioUnit). + + All plugin identifiers share the same processor factory (which calls + loadPluginFromProperties) and a view factory that wraps the loaded + AudioPluginInstance in a PluginNodeView. + + @param scanner The plugin scanner that provides access to format backends. + @param ctx The host context passed to the format's loadPlugin call. + */ + void registerPluginFormats (yup::AudioPluginScanner* scanner, yup::AudioPluginHostContext ctx) + { + pluginScanner = scanner; + hostContext = ctx; + + auto processorFactory = [this] (const yup::AudioGraphNodeProperties& props) + -> yup::ResultValue> + { + return loadPluginFromProperties (props); + }; + + auto viewFactory = [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) + -> std::unique_ptr + { + auto* instance = dynamic_cast (proc); + if (instance == nullptr) + return nullptr; + + return std::make_unique (nodeID, instance->getDescription()); + }; + + entries[pluginVst3Identifier] = { processorFactory, viewFactory }; + entries[pluginClapIdentifier] = { processorFactory, viewFactory }; + entries[pluginAuIdentifier] = { processorFactory, viewFactory }; + } + + //============================================================================== + /** + Replaces the current list of discovered plugins. + + @param plugins A vector of plugin descriptions, typically obtained from + AudioPluginScanner::scan() or scanDefaults(). + */ + void setDiscoveredPlugins (std::vector plugins) + { + discoveredPlugins = std::move (plugins); + } + + const std::vector& getDiscoveredPlugins() const noexcept + { + return discoveredPlugins; + } + + //============================================================================== + /** + Returns the node registry identifier that corresponds to the given plugin description. + + @param desc A plugin description whose formatType drives the selection. + @returns One of pluginVst3Identifier, pluginClapIdentifier, or pluginAuIdentifier. + */ + static yup::String identifierForDescription (const yup::AudioPluginDescription& desc) + { + switch (desc.formatType) + { + case yup::AudioPluginFormatType::vst3: + return pluginVst3Identifier; + case yup::AudioPluginFormatType::clap: + return pluginClapIdentifier; + case yup::AudioPluginFormatType::audioUnit: + return pluginAuIdentifier; + default: + return "plugin.unknown"; + } + } + + //============================================================================== + /** + Serialises a plugin description into an opaque MemoryBlock for use as + AudioGraphNodeProperties::creationData. + + The data is encoded as XML and can be decoded by loadPluginFromProperties(). + + @param desc The plugin description to encode. + @returns A MemoryBlock containing the serialised XML data. + */ + static yup::MemoryBlock descriptionToCreationData (const yup::AudioPluginDescription& desc) + { + yup::XmlElement xml ("PluginDescription"); + xml.setAttribute ("name", desc.name); + xml.setAttribute ("vendor", desc.vendor); + xml.setAttribute ("version", desc.version); + xml.setAttribute ("identifier", desc.identifier); + xml.setAttribute ("fileOrBundlePath", desc.fileOrBundlePath); + xml.setAttribute ("isInstrument", desc.isInstrument ? 1 : 0); + xml.setAttribute ("isEffect", desc.isEffect ? 1 : 0); + xml.setAttribute ("numInputChannels", desc.numInputChannels); + xml.setAttribute ("numOutputChannels", desc.numOutputChannels); + + yup::String formatTypeStr; + switch (desc.formatType) + { + case yup::AudioPluginFormatType::vst3: + formatTypeStr = "vst3"; + break; + case yup::AudioPluginFormatType::clap: + formatTypeStr = "clap"; + break; + case yup::AudioPluginFormatType::audioUnit: + formatTypeStr = "au"; + break; + default: + formatTypeStr = "unknown"; + break; + } + xml.setAttribute ("formatType", formatTypeStr); + + yup::MemoryBlock block; + yup::MemoryOutputStream stream (block, false); + xml.writeTo (stream); + stream.flush(); + + return block; + } + + //============================================================================== + /** + Updates the stored plugin scanner and host context. + + Call this when the audio device becomes available and the real sample rate + and block size are known. + + @param s The plugin scanner to use for loading plugins. + @param ctx The updated host context. + */ + void setPluginScanner (yup::AudioPluginScanner* s, yup::AudioPluginHostContext ctx) + { + pluginScanner = s; + hostContext = ctx; + } +#endif + + //============================================================================== + /** + Returns an AudioGraphProcessor::NodeFactory that dispatches to the + registered processor factory for a given node identifier. + + If no entry is found for props.identifier, the factory returns a failure + result. + */ + yup::AudioGraphProcessor::NodeFactory makeProcessorFactory() + { + return [this] (const yup::AudioGraphNodeProperties& props) + -> yup::ResultValue> + { + auto it = entries.find (props.identifier); + if (it == entries.end()) + return yup::makeResultValueFail ("Unknown node: " + props.identifier); + + return it->second.createProcessor (props); + }; + } + + //============================================================================== + /** + Creates the visual representation for an already-constructed node. + + @param nodeID The stable graph node identifier. + @param identifier The registry key identifying the node type. + @param proc The processor that was created for this node. + @returns A new view, or nullptr if the identifier is unknown. + */ + std::unique_ptr createView (yup::AudioGraphNodeID nodeID, + const yup::String& identifier, + yup::AudioProcessor* proc) + { + auto it = entries.find (identifier); + if (it == entries.end()) + return nullptr; + + return it->second.createView (nodeID, proc); + } + + //============================================================================== + std::vector getInternalNodeIdentifiers() const + { + return { oscillatorIdentifier, gainIdentifier, lpfIdentifier, samplePlayerIdentifier }; + } + + //============================================================================== + /** + Maps a node registry identifier to a human-readable display name. + + @param id A registry identifier string. + @returns A display name such as "Oscillator", "Gain", or "Low Pass Filter", + or the original identifier if unrecognised. + */ + static yup::String identifierToDisplayName (const yup::String& id) + { + if (id == oscillatorIdentifier) + return "Oscillator"; + if (id == gainIdentifier) + return "Gain"; + if (id == lpfIdentifier) + return "Low Pass Filter"; + if (id == samplePlayerIdentifier) + return "Sample Player"; + + return id; + } + +private: + //============================================================================== + std::map entries; + +#if YUP_DESKTOP + yup::AudioPluginScanner* pluginScanner = nullptr; + yup::AudioPluginHostContext hostContext; + std::vector discoveredPlugins; + + //============================================================================== + /** + Decodes AudioGraphNodeProperties::creationData and loads the described plugin. + + @param props Node properties whose creationData holds serialised XML. + @returns A ResultValue containing the loaded AudioPluginInstance on success, + or an error message on failure. + */ + yup::ResultValue> loadPluginFromProperties ( + const yup::AudioGraphNodeProperties& props) + { + if (pluginScanner == nullptr) + return yup::makeResultValueFail ("No plugin scanner available"); + + yup::MemoryInputStream stream (props.creationData, false); + const yup::String xmlText = stream.readEntireStreamAsString(); + + auto xml = yup::parseXML (xmlText); + if (xml == nullptr) + return yup::makeResultValueFail ("Failed to parse plugin description XML"); + + yup::AudioPluginDescription desc; + desc.name = xml->getStringAttribute ("name"); + desc.vendor = xml->getStringAttribute ("vendor"); + desc.version = xml->getStringAttribute ("version"); + desc.identifier = xml->getStringAttribute ("identifier"); + desc.fileOrBundlePath = xml->getStringAttribute ("fileOrBundlePath"); + desc.isInstrument = xml->getBoolAttribute ("isInstrument", false); + desc.isEffect = xml->getBoolAttribute ("isEffect", false); + desc.numInputChannels = xml->getIntAttribute ("numInputChannels", 0); + desc.numOutputChannels = xml->getIntAttribute ("numOutputChannels", 0); + + const yup::String formatTypeStr = xml->getStringAttribute ("formatType"); + if (formatTypeStr == "vst3") + desc.formatType = yup::AudioPluginFormatType::vst3; + else if (formatTypeStr == "clap") + desc.formatType = yup::AudioPluginFormatType::clap; + else if (formatTypeStr == "au") + desc.formatType = yup::AudioPluginFormatType::audioUnit; + else + return yup::makeResultValueFail ("Unknown plugin format in creation data: " + formatTypeStr); + + auto* format = pluginScanner->getFormatForType (desc.formatType); + if (format == nullptr) + return yup::makeResultValueFail ("No format backend registered for type: " + formatTypeStr); + + auto result = format->loadPlugin (desc, hostContext); + if (result.failed()) + return yup::makeResultValueFail (result.getErrorMessage()); + + std::unique_ptr processor = std::move (result).getValue(); + return yup::makeResultValueOk (std::move (processor)); + } +#endif +}; diff --git a/examples/audiograph/source/nodes/NodeViewHelpers.h b/examples/audiograph/source/nodes/NodeViewHelpers.h new file mode 100644 index 000000000..1bf571659 --- /dev/null +++ b/examples/audiograph/source/nodes/NodeViewHelpers.h @@ -0,0 +1,48 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +namespace NodeViewHelpers +{ + +inline void configureParameterSlider (yup::Slider& slider, yup::Color accent) +{ + slider.setSliderType (yup::Slider::LinearBarHorizontal); + slider.setTextBoxStyle (yup::Slider::NoTextBox); + slider.setColor (yup::Slider::Style::backgroundColorId, yup::Color (0xff26282c)); + slider.setColor (yup::Slider::Style::trackColorId, accent.withAlpha (0.65f)); + slider.setColor (yup::Slider::Style::thumbColorId, accent); + slider.setColor (yup::Slider::Style::thumbOverColorId, accent.brighter (0.15f)); + slider.setColor (yup::Slider::Style::thumbDownColorId, accent.darker (0.15f)); +} + +inline yup::Rectangle getInlineSliderBounds (const yup::Component& component, int preferredWidth) +{ + const auto bounds = component.getLocalBounds(); + const auto scale = bounds.getWidth() / static_cast (preferredWidth); + return { 62.0f * scale, + 49.0f * scale, + yup::jmax (42.0f * scale, bounds.getWidth() - (150.0f * scale)), + 20.0f * scale }; +} + +} // namespace NodeViewHelpers diff --git a/examples/audiograph/source/nodes/OscillatorNode.h b/examples/audiograph/source/nodes/OscillatorNode.h new file mode 100644 index 000000000..e494c34ad --- /dev/null +++ b/examples/audiograph/source/nodes/OscillatorNode.h @@ -0,0 +1,147 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include + +#include "NodeViewHelpers.h" + +//============================================================================== +class OscillatorProcessor final : public yup::AudioProcessor +{ +public: + OscillatorProcessor() + : AudioProcessor ("Oscillator", + yup::AudioBusLayout ({}, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + } + + void prepareToPlay (float newSampleRate, int) override + { + sampleRate = newSampleRate; + phase = 0.0; + } + + void releaseResources() override {} + + void processBlock (yup::AudioBuffer& audioBuffer, yup::MidiBuffer&) override + { + const auto currentFrequency = static_cast (frequency.load (std::memory_order_relaxed)); + const auto increment = yup::MathConstants::twoPi * currentFrequency / static_cast (sampleRate); + + for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) + { + const auto value = static_cast (std::sin (phase) * 0.22); + + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + audioBuffer.setSample (channel, sample, value); + + phase += increment; + if (phase >= yup::MathConstants::twoPi) + phase -= yup::MathConstants::twoPi; + } + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + yup::Result loadStateFromMemory (const yup::MemoryBlock&) override { return yup::Result::ok(); } + + yup::Result saveStateIntoMemory (yup::MemoryBlock&) override { return yup::Result::ok(); } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + double getFrequency() const noexcept { return static_cast (frequency.load (std::memory_order_relaxed)); } + + void setFrequency (double newFrequency) noexcept + { + frequency.store (static_cast (yup::jlimit (40.0, 2000.0, newFrequency)), std::memory_order_relaxed); + } + +private: + double sampleRate = 44100.0; + double phase = 0.0; + std::atomic frequency { 440.0f }; +}; + +//============================================================================== +class OscillatorNodeView final : public yup::AudioGraphNodeView +{ +public: + OscillatorNodeView (yup::AudioGraphNodeID nodeID, OscillatorProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , frequencySlider (yup::Slider::LinearBarHorizontal) + { + NodeViewHelpers::configureParameterSlider (frequencySlider, getPortKindColor (PortKind::parameter)); + frequencySlider.setRange (40.0, 2000.0); + frequencySlider.setSkewFactorFromMidpoint (440.0); + frequencySlider.setValue (processor.getFrequency(), yup::dontSendNotification); + frequencySlider.onValueChanged = [this] (double value) + { + processor.setFrequency (value); + repaint(); + }; + addAndMakeVisible (frequencySlider); + } + + yup::String getNodeTitle() const override { return "OSC"; } + + int getNumInputPorts() const override { return 0; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 220; } + + yup::Color getNodeColor() const override { return yup::Color (0xff2563eb); } + + yup::String getNodeSubtitle() const override { return yup::String (processor.getFrequency(), 0) + " Hz"; } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 1; } + + ParameterInfo getParameterInfo (int) const override + { + return { "Frequency", yup::String (processor.getFrequency(), 0), getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + } + + void resized() override + { + frequencySlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); + } + +private: + OscillatorProcessor& processor; + yup::Slider frequencySlider; +}; diff --git a/examples/audiograph/source/nodes/PluginNodeView.h b/examples/audiograph/source/nodes/PluginNodeView.h new file mode 100644 index 000000000..3ce9ea68d --- /dev/null +++ b/examples/audiograph/source/nodes/PluginNodeView.h @@ -0,0 +1,80 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +class PluginNodeView final : public yup::AudioGraphNodeView +{ +public: + PluginNodeView (yup::AudioGraphNodeID nodeID, + const yup::AudioPluginDescription& descIn) + : AudioGraphNodeView (nodeID) + , desc (descIn) + { + } + + yup::String getNodeTitle() const override + { + return desc.name.isEmpty() ? "Plugin" : desc.name; + } + + yup::String getNodeSubtitle() const override + { + return desc.vendor; + } + + int getNumInputPorts() const override + { + return desc.numInputChannels > 0 ? 1 : 0; + } + + int getNumOutputPorts() const override + { + return desc.numOutputChannels > 0 ? 1 : 0; + } + + int getPreferredWidth() const override + { + return yup::jmax (180, static_cast (desc.name.length()) * 10 + 60); + } + + yup::Color getNodeColor() const override + { + return desc.isInstrument ? yup::Color (0xffe11d48) : yup::Color (0xff0891b2); + } + + PortInfo getInputPortInfo (int) const override + { + return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; + } + + PortInfo getOutputPortInfo (int) const override + { + return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; + } + + int getNumParameterRows() const override { return 0; } + + const yup::AudioPluginDescription& getDescription() const noexcept { return desc; } + +private: + yup::AudioPluginDescription desc; +}; diff --git a/examples/audiograph/source/nodes/SamplePlayerNode.h b/examples/audiograph/source/nodes/SamplePlayerNode.h new file mode 100644 index 000000000..c22620e9c --- /dev/null +++ b/examples/audiograph/source/nodes/SamplePlayerNode.h @@ -0,0 +1,426 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include +#include +#include + +#include "NodeViewHelpers.h" + +//============================================================================== +class SamplePlayerProcessor final : public yup::AudioProcessor +{ +public: + struct SampleData + { + yup::AudioBuffer buffer; + double sampleRate = 44100.0; + yup::File file; + }; + + SamplePlayerProcessor() + : AudioProcessor ("Sample Player", + yup::AudioBusLayout ({}, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + } + + void prepareToPlay (float newSampleRate, int) override + { + playbackSampleRate.store (newSampleRate > 0.0f ? newSampleRate : 44100.0f, std::memory_order_relaxed); + } + + void releaseResources() override {} + + void processBlock (yup::AudioBuffer& audioBuffer, yup::MidiBuffer&) override + { + audioBuffer.clear(); + + const auto* sample = currentSample.load (std::memory_order_acquire); + if (sample == nullptr || sample->buffer.getNumSamples() <= 0 || sample->buffer.getNumChannels() <= 0) + return; + + const auto& source = sample->buffer; + const int sourceNumSamples = source.getNumSamples(); + const int sourceNumChannels = source.getNumChannels(); + const double outputSampleRate = static_cast (playbackSampleRate.load (std::memory_order_relaxed)); + const double sourceSampleRate = sample->sampleRate > 0.0 ? sample->sampleRate : outputSampleRate; + const double positionIncrement = sourceSampleRate / yup::jmax (1.0, outputSampleRate); + + double position = playbackPosition.load (std::memory_order_relaxed); + + while (position >= static_cast (sourceNumSamples)) + position -= static_cast (sourceNumSamples); + + for (int sampleIndex = 0; sampleIndex < audioBuffer.getNumSamples(); ++sampleIndex) + { + const int index0 = yup::jlimit (0, sourceNumSamples - 1, static_cast (position)); + const int index1 = (index0 + 1) < sourceNumSamples ? index0 + 1 : 0; + const float alpha = static_cast (position - static_cast (index0)); + + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + { + const int sourceChannel = yup::jmin (channel, sourceNumChannels - 1); + const float s0 = source.getSample (sourceChannel, index0); + const float s1 = source.getSample (sourceChannel, index1); + audioBuffer.setSample (channel, sampleIndex, s0 + (s1 - s0) * alpha); + } + + position += positionIncrement; + while (position >= static_cast (sourceNumSamples)) + position -= static_cast (sourceNumSamples); + } + + playbackPosition.store (position, std::memory_order_relaxed); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + yup::String getPresetName (int) const override { return {}; } + + void setPresetName (int, yup::StringRef) override {} + + yup::Result loadStateFromMemory (const yup::MemoryBlock& data) override + { + if (data.isEmpty()) + return yup::Result::ok(); + + yup::MemoryInputStream stream (data, false); + const auto path = stream.readEntireStreamAsString(); + + if (path.isEmpty()) + return yup::Result::ok(); + + const auto result = loadSampleFile (yup::File (path)); + return result.wasOk() ? result : yup::Result::ok(); + } + + yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + { + yup::MemoryOutputStream stream (data, false); + stream.writeText (getSampleFile().getFullPathName(), false, false, nullptr); + stream.flush(); + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + yup::Result loadSampleFile (const yup::File& file) + { + if (! file.existsAsFile()) + return yup::Result::fail ("File not found: " + file.getFullPathName()); + + yup::AudioFormatManager formatManager; + formatManager.registerDefaultFormats(); + + auto reader = formatManager.createReaderFor (file); + if (reader == nullptr) + return yup::Result::fail ("Unsupported or unreadable format: " + file.getFileName()); + + if (reader->numChannels <= 0 || reader->lengthInSamples <= 0) + return yup::Result::fail ("The selected file contains no audio."); + + if (reader->lengthInSamples > static_cast (std::numeric_limits::max())) + return yup::Result::fail ("The selected file is too long to load into memory."); + + auto data = std::make_shared(); + data->sampleRate = reader->sampleRate > 0.0 ? reader->sampleRate : 44100.0; + data->file = file; + data->buffer.setSize (reader->numChannels, static_cast (reader->lengthInSamples)); + + if (! reader->read (&data->buffer, 0, data->buffer.getNumSamples(), 0, true, true)) + return yup::Result::fail ("Could not read audio from: " + file.getFileName()); + + auto retainedSample = std::shared_ptr (std::move (data)); + currentSampleForUi = retainedSample; + retainedSamples.push_back (std::move (retainedSample)); + currentSample.store (currentSampleForUi.get(), std::memory_order_release); + + playbackPosition.store (0.0, std::memory_order_relaxed); + return yup::Result::ok(); + } + + std::shared_ptr getCurrentSample() const + { + return currentSampleForUi; + } + + yup::File getSampleFile() const + { + const auto sample = getCurrentSample(); + return sample != nullptr ? sample->file : yup::File(); + } + + double getPlaybackPositionSamples() const noexcept + { + return playbackPosition.load (std::memory_order_relaxed); + } + +private: + std::atomic currentSample { nullptr }; + std::shared_ptr currentSampleForUi; + // Keeps previously loaded buffers alive because the audio thread reads currentSample as a raw pointer. + std::vector> retainedSamples; + std::atomic playbackPosition { 0.0 }; + std::atomic playbackSampleRate { 44100.0f }; +}; + +//============================================================================== +class SampleWaveformThumbnail final : public yup::AudioThumbnail +{ +public: + explicit SampleWaveformThumbnail (yup::Color waveformColorIn) + : waveformColor (waveformColorIn) + { + } + + yup::Color getChannelColor (int) const override + { + return waveformColor; + } + +private: + yup::Color waveformColor; +}; + +//============================================================================== +class SampleWaveformComponent final + : public yup::Component + , public yup::AudioThumbnail::Listener + , public yup::Timer +{ +public: + SampleWaveformComponent (SamplePlayerProcessor& processorIn, yup::Color waveformColor) + : processor (processorIn) + , thumbnail (waveformColor) + , accentColor (waveformColor) + { + thumbnail.addListener (this); + setOpaque (false); + } + + ~SampleWaveformComponent() override + { + stopTimer(); + thumbnail.removeListener (this); + } + + void setSample (std::shared_ptr sample) + { + currentSample = std::move (sample); + + if (currentSample == nullptr) + { + thumbnail.clear(); + stopTimer(); + repaint(); + return; + } + + thumbnail.setSource (currentSample->buffer, currentSample->sampleRate); + startTimerHz (30); + repaint(); + } + + void paint (yup::Graphics& g) override + { + const auto bounds = getLocalBounds().to(); + + if (currentSample == nullptr || thumbnail.getTotalSamples() <= 0) + { + g.setFillColor (accentColor.withAlpha (0.58f)); + g.fillFittedText ("Load sample", yup::ApplicationTheme::getGlobalTheme()->getDefaultFont().withHeight (10.0f), bounds.reduced (6.0f), yup::Justification::center); + return; + } + + const int numChannels = yup::jmin (2, thumbnail.getNumChannels()); + const auto sampleRange = yup::Range (0.0, static_cast (thumbnail.getTotalSamples())); + const auto laneBounds = bounds.reduced (5.0f, 3.0f); + const float laneHeight = laneBounds.getHeight() / static_cast (numChannels); + + for (int channel = 0; channel < numChannels; ++channel) + { + const auto lane = laneBounds.withY (laneBounds.getY() + static_cast (channel) * laneHeight) + .withHeight (laneHeight - 2.0f); + + g.setFillColor (accentColor.withAlpha (0.12f)); + g.fillRect ({ lane.getX(), lane.getCenterY(), lane.getWidth(), 1.0f }); + + thumbnail.paintChannel (g, lane, channel, sampleRange, lane.getWidth()); + } + + const auto playheadX = yup::jlimit (laneBounds.getX(), + laneBounds.getRight() - 1.5f, + laneBounds.getX() + + laneBounds.getWidth() + * static_cast (yup::jlimit (0.0, + 1.0, + processor.getPlaybackPositionSamples() + / static_cast (thumbnail.getTotalSamples())))); + + g.setFillColor (accentColor.withAlpha (0.95f)); + g.fillRect ({ playheadX, laneBounds.getY(), 1.5f, laneBounds.getHeight() }); + + if (thumbnail.isProgressVisible()) + { + const auto progressBounds = bounds.withY (bounds.getBottom() - 3.0f).withHeight (3.0f); + g.setFillColor (accentColor.withAlpha (0.80f)); + g.fillRect (progressBounds.withWidth (progressBounds.getWidth() * static_cast (thumbnail.getProgress()))); + } + } + +private: + void thumbnailChanged (yup::AudioThumbnail&) override { repaint(); } + + void thumbnailProgressChanged (yup::AudioThumbnail&, double, bool) override { repaint(); } + + void timerCallback() override { repaint(); } + + SamplePlayerProcessor& processor; + SampleWaveformThumbnail thumbnail; + yup::Color accentColor; + std::shared_ptr currentSample; +}; + +//============================================================================== +class SamplePlayerNodeView final : public yup::AudioGraphNodeView +{ +public: + SamplePlayerNodeView (yup::AudioGraphNodeID nodeID, SamplePlayerProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , waveform (processorIn, getNodeColor()) + { + setColor (Style::parameterBackgroundColorId, yup::Color (0x00000000)); + setColor (Style::parameterValueBackgroundColorId, yup::Color (0x00000000)); + + loadButton.setButtonText ("Load"); + loadButton.onClick = [this] + { + chooseSampleFile(); + }; + addAndMakeVisible (loadButton); + + waveform.setSample (processor.getCurrentSample()); + addAndMakeVisible (waveform); + } + + yup::String getNodeTitle() const override { return "SAMPLE"; } + + int getNumInputPorts() const override { return 0; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 260; } + + yup::Color getNodeColor() const override { return yup::Color (0xff38bdf8); } + + yup::String getNodeSubtitle() const override + { + if (loadError.isNotEmpty()) + return loadError; + + const auto sample = processor.getCurrentSample(); + return sample != nullptr ? sample->file.getFileName() : "loop player"; + } + + PortInfo getOutputPortInfo (int) const override { return { "audio", getPortKindColor (PortKind::audio), PortKind::audio }; } + + int getNumParameterRows() const override { return 3; } + + ParameterInfo getParameterInfo (int) const override + { + return { {}, {}, getNodeColor(), -1.0f, PortKind::parameter }; + } + + void resized() override + { + const auto scale = getLocalBounds().getWidth() / static_cast (getPreferredWidth()); + auto body = getLocalBounds().to().reduced (14.0f * scale, 0.0f); + + loadButton.setBounds (body.getX() + 8.0f * scale, + 43.0f * scale, + 62.0f * scale, + 20.0f * scale); + + waveform.setBounds (body.getX() + 8.0f * scale, + 67.0f * scale, + body.getWidth() - 16.0f * scale, + 52.0f * scale); + } + +private: + void chooseSampleFile() + { + fileChooser = yup::FileChooser::create ( + "Load Sample", + processor.getSampleFile().existsAsFile() + ? processor.getSampleFile().getParentDirectory() + : yup::File::getSpecialLocation (yup::File::userMusicDirectory), + "*.wav;*.aif;*.aiff;*.flac;*.mp3;*.ogg"); + + yup::WeakReference weakThis (this); + + fileChooser->browseForFileToOpen ([weakThis] (bool success, const yup::Array& results) + { + auto* self = weakThis.get(); + if (self == nullptr || ! success || results.isEmpty()) + return; + + self->loadSample (results[0]); + }); + } + + void loadSample (const yup::File& file) + { + const auto result = processor.loadSampleFile (file); + + if (result.wasOk()) + { + loadError.clear(); + loadButton.setButtonText ("Load"); + waveform.setSample (processor.getCurrentSample()); + repaint(); + return; + } + + loadError = result.getErrorMessage(); + loadButton.setButtonText ("Retry"); + repaint(); + } + + SamplePlayerProcessor& processor; + yup::TextButton loadButton; + SampleWaveformComponent waveform; + yup::FileChooser::Ptr fileChooser; + yup::String loadError; + + YUP_DECLARE_WEAK_REFERENCEABLE (SamplePlayerNodeView) +}; diff --git a/examples/audiograph/source/ui/PluginEditorWindow.h b/examples/audiograph/source/ui/PluginEditorWindow.h new file mode 100644 index 000000000..daec843f5 --- /dev/null +++ b/examples/audiograph/source/ui/PluginEditorWindow.h @@ -0,0 +1,67 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include + +class PluginEditorWindow final : public yup::DocumentWindow +{ +public: + PluginEditorWindow (yup::StringRef windowTitle, + std::unique_ptr editorToOwn, + std::function onCloseCallback) + : yup::DocumentWindow (makeWindowOptions (editorToOwn.get()), yup::Color (0xff101417)) + , editor (std::move (editorToOwn)) + , onClose (std::move (onCloseCallback)) + { + setTitle (windowTitle); + addAndMakeVisible (*editor); + editor->attachedToNative(); + takeKeyboardFocus(); + } + + void resized() override + { + editor->setBounds (getLocalBounds()); + } + + void userTriedToCloseWindow() override + { + if (onClose != nullptr) + { + yup::MessageManager::callAsync (onClose); + } + } + +private: + static yup::ComponentNative::Options makeWindowOptions (yup::AudioProcessorEditor* editor) + { + return yup::ComponentNative::Options() + .withResizableWindow (editor != nullptr && editor->isResizable()) + .withFramerateRedraw (0) + .withRenderContinuous (false); + } + + std::unique_ptr editor; + std::function onClose; +}; diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 8d0dd340e..ff70a6a15 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -48,10 +48,12 @@ endif() # ==== Prepare target set (additional_modules "") +set (additional_definitions "") if (YUP_PLATFORM_DESKTOP AND NOT YUP_PLATFORM_WINDOWS) set (additional_modules yup::yup_python) endif() + yup_standalone_app ( TARGET_NAME ${target_name} TARGET_VERSION ${target_version} @@ -60,6 +62,7 @@ yup_standalone_app ( TARGET_APP_NAMESPACE "org.yup" DEFINITIONS YUP_EXAMPLE_GRAPHICS_RIVE_FILE="${rive_file}" + ${additional_definitions} PRELOAD_FILES "${CMAKE_CURRENT_LIST_DIR}/${rive_file}@${rive_file}" MODULES diff --git a/examples/graphics/source/examples/AudioFileDemo.h b/examples/graphics/source/examples/AudioFileDemo.h index a90bed4fb..227da802f 100644 --- a/examples/graphics/source/examples/AudioFileDemo.h +++ b/examples/graphics/source/examples/AudioFileDemo.h @@ -818,7 +818,7 @@ class AudioFileDemo : public yup::Component timeLabel.setColor (yup::Label::Style::textFillColorId, yup::Colors::white); addAndMakeVisible (timeStretchSlider); - timeStretchSlider.setSliderType (yup::Slider::LinearBarHorizontal); + timeStretchSlider.setSliderType (yup::Slider::LinearHorizontal); timeStretchSlider.setRange (0.5, 2.0, 0.01); timeStretchSlider.setValue (1.0, yup::NotificationType::dontSendNotification); timeStretchSlider.setDefaultValue (1.0); @@ -837,7 +837,7 @@ class AudioFileDemo : public yup::Component pitchLabel.setColor (yup::Label::Style::textFillColorId, yup::Colors::white); addAndMakeVisible (pitchShiftSlider); - pitchShiftSlider.setSliderType (yup::Slider::LinearBarHorizontal); + pitchShiftSlider.setSliderType (yup::Slider::LinearHorizontal); pitchShiftSlider.setRange (0.5, 2.0, 0.01); pitchShiftSlider.setValue (1.0, yup::NotificationType::dontSendNotification); pitchShiftSlider.setDefaultValue (1.0); diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphAllocationStats.h b/modules/yup_audio_graph/graph/yup_AudioGraphAllocationStats.h new file mode 100644 index 000000000..168f0dc6c --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphAllocationStats.h @@ -0,0 +1,50 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + Lightweight diagnostics describing the most recently compiled graph plan. +*/ +struct AudioGraphAllocationStats +{ + /** Number of preallocated node scratch audio buffers. */ + int scratchAudioBuffers = 0; + + /** Number of preallocated node MIDI buffers. */ + int midiBuffers = 0; + + /** Number of per-connection delay lines. */ + int delayLines = 0; + + /** Maximum latency compensation inserted on any graph output path. */ + int totalCompensationSamples = 0; + + /** Maximum preallocated audio channel count used by any node or graph endpoint. */ + int maxPreallocatedChannels = 0; + + /** Maximum preallocated block size for the compiled plan. */ + int maxPreallocatedBlockSize = 0; +}; + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphConnection.h b/modules/yup_audio_graph/graph/yup_AudioGraphConnection.h new file mode 100644 index 000000000..89d632aa0 --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphConnection.h @@ -0,0 +1,55 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + A directed connection between two graph endpoints. +*/ +class AudioGraphConnection +{ +public: + AudioGraphConnection() = default; + + /** Creates a connection from sourceEndpoint to destinationEndpoint. */ + AudioGraphConnection (AudioGraphEndpoint sourceEndpoint, AudioGraphEndpoint destinationEndpoint) noexcept + : source (sourceEndpoint) + , destination (destinationEndpoint) + { + } + + bool operator== (const AudioGraphConnection& other) const noexcept + { + return source == other.source && destination == other.destination; + } + + bool operator!= (const AudioGraphConnection& other) const noexcept { return ! (*this == other); } + + /** Source endpoint. */ + AudioGraphEndpoint source; + + /** Destination endpoint. */ + AudioGraphEndpoint destination; +}; + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphEndpoint.h b/modules/yup_audio_graph/graph/yup_AudioGraphEndpoint.h new file mode 100644 index 000000000..c17f40c62 --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphEndpoint.h @@ -0,0 +1,107 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + Describes one routable audio or MIDI endpoint in an AudioGraphProcessor. + + Source endpoints are graphInput() and nodeOutput(). Destination endpoints are + graphOutput() and nodeInput(). The bus index refers to the AudioBusLayout of the + graph or the addressed processor node. +*/ +class AudioGraphEndpoint +{ +public: + /** The endpoint owner and direction. */ + enum class Kind + { + graphInput, + graphOutput, + nodeInput, + nodeOutput + }; + + /** Creates an invalid graph input endpoint. */ + AudioGraphEndpoint() = default; + + /** Creates a source endpoint for one graph input bus. */ + static AudioGraphEndpoint graphInput (int busIndex) noexcept + { + return AudioGraphEndpoint (Kind::graphInput, AudioGraphNodeID::invalid(), busIndex); + } + + /** Creates a destination endpoint for one graph output bus. */ + static AudioGraphEndpoint graphOutput (int busIndex) noexcept + { + return AudioGraphEndpoint (Kind::graphOutput, AudioGraphNodeID::invalid(), busIndex); + } + + /** Creates a destination endpoint for one node input bus. */ + static AudioGraphEndpoint nodeInput (AudioGraphNodeID nodeID, int busIndex) noexcept + { + return AudioGraphEndpoint (Kind::nodeInput, nodeID, busIndex); + } + + /** Creates a source endpoint for one node output bus. */ + static AudioGraphEndpoint nodeOutput (AudioGraphNodeID nodeID, int busIndex) noexcept + { + return AudioGraphEndpoint (Kind::nodeOutput, nodeID, busIndex); + } + + /** Returns the endpoint kind. */ + Kind getKind() const noexcept { return kind; } + + /** Returns the addressed node, or an invalid ID for graph endpoints. */ + AudioGraphNodeID getNodeID() const noexcept { return nodeID; } + + /** Returns the bus index on the graph or processor layout. */ + int getBusIndex() const noexcept { return busIndex; } + + /** Returns true when this endpoint can appear as a connection source. */ + bool isSource() const noexcept { return kind == Kind::graphInput || kind == Kind::nodeOutput; } + + /** Returns true when this endpoint can appear as a connection destination. */ + bool isDestination() const noexcept { return kind == Kind::graphOutput || kind == Kind::nodeInput; } + + bool operator== (const AudioGraphEndpoint& other) const noexcept + { + return kind == other.kind && nodeID == other.nodeID && busIndex == other.busIndex; + } + + bool operator!= (const AudioGraphEndpoint& other) const noexcept { return ! (*this == other); } + +private: + AudioGraphEndpoint (Kind endpointKind, AudioGraphNodeID endpointNodeID, int endpointBusIndex) noexcept + : kind (endpointKind) + , nodeID (endpointNodeID) + , busIndex (endpointBusIndex) + { + } + + Kind kind = Kind::graphInput; + AudioGraphNodeID nodeID; + int busIndex = -1; +}; + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphNodeID.h b/modules/yup_audio_graph/graph/yup_AudioGraphNodeID.h new file mode 100644 index 000000000..9f40da5fb --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphNodeID.h @@ -0,0 +1,63 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + Opaque identifier for a processor node owned by an AudioGraphProcessor. + + Node identifiers remain stable until the node is removed. The invalid identifier + is used by graph input and graph output endpoints, and by failed addNode calls. +*/ +class AudioGraphNodeID +{ +public: + /** Creates an invalid node identifier. */ + constexpr AudioGraphNodeID() noexcept = default; + + /** Returns an invalid node identifier. */ + static constexpr AudioGraphNodeID invalid() noexcept { return {}; } + + /** Returns true when this identifier names a graph node. */ + constexpr bool isValid() const noexcept { return value != 0; } + + /** Returns the raw integer identifier. */ + constexpr uint64_t getRawID() const noexcept { return value; } + + /** Creates an identifier from a raw value. Prefer IDs returned by addNode(). */ + explicit constexpr AudioGraphNodeID (uint64_t rawValue) noexcept + : value (rawValue) + { + } + + constexpr bool operator== (AudioGraphNodeID other) const noexcept { return value == other.value; } + + constexpr bool operator!= (AudioGraphNodeID other) const noexcept { return value != other.value; } + + constexpr bool operator< (AudioGraphNodeID other) const noexcept { return value < other.value; } + +private: + uint64_t value = 0; +}; + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphNodeProperties.h b/modules/yup_audio_graph/graph/yup_AudioGraphNodeProperties.h new file mode 100644 index 000000000..d0f98fdcd --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphNodeProperties.h @@ -0,0 +1,52 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + Persistent metadata used to save and recreate an AudioGraphProcessor node. + + Set identifier to a stable application-defined factory key. If node creation + needs additional metadata, store it in creationData. Callers that also link a + plugin-host module can encode plugin descriptions there and recreate hosted + plugin instances from the node factory. +*/ +struct AudioGraphNodeProperties +{ + /** Stable factory key used when loading graph state. */ + String identifier; + + /** Human-readable node name. */ + String name; + + /** Canvas X position in caller-defined units. */ + float positionX = 0.0f; + + /** Canvas Y position in caller-defined units. */ + float positionY = 0.0f; + + /** Opaque caller-defined metadata used to recreate the node. */ + MemoryBlock creationData; +}; + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp new file mode 100644 index 000000000..92ffd7835 --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp @@ -0,0 +1,1808 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +namespace +{ +enum class GraphSignalType +{ + audio, + midi +}; + +struct BusDescriptor +{ + GraphSignalType type = GraphSignalType::audio; + int channels = 0; + int offset = 0; +}; + +int getTotalAudioChannels (Span buses) +{ + int result = 0; + + for (const auto& bus : buses) + if (bus.getType() == AudioBus::Type::Audio) + result += bus.getNumChannels(); + + return result; +} + +std::vector getAudioBusOffsets (Span buses) +{ + std::vector offsets (static_cast (buses.size()), 0); + int offset = 0; + + for (int i = 0; i < static_cast (buses.size()); ++i) + { + offsets[static_cast (i)] = offset; + + if (buses[static_cast (i)].getType() == AudioBus::Type::Audio) + offset += buses[static_cast (i)].getNumChannels(); + } + + return offsets; +} + +GraphSignalType toSignalType (AudioBus::Type busType) +{ + return busType == AudioBus::Type::Audio ? GraphSignalType::audio : GraphSignalType::midi; +} + +bool isValidBusIndex (Span buses, int busIndex) +{ + return busIndex >= 0 && busIndex < static_cast (buses.size()); +} + +constexpr int audioGraphStateVersion = 1; +constexpr const char* audioGraphStateTag = "YUPAudioGraphState"; +} // namespace + +//============================================================================== +class AudioGraphProcessor::Pimpl +{ +public: + explicit Pimpl (AudioGraphProcessor& ownerIn) + : owner (ownerIn) + { + } + + ~Pimpl() + { + resizeWorkers (0); + delete pendingPlan.exchange (nullptr); + delete currentPlan; + deleteRetiredPlans(); + } + + struct ModelNode + { + AudioGraphNodeID id; + std::shared_ptr processor; + AudioGraphNodeProperties properties; + float preparedSampleRate = 0.0f; + int preparedBlockSize = 0; + bool prepared = false; + }; + + struct DelayLine + { + void initialise (GraphSignalType signalTypeIn, int numChannels, int delaySamplesIn, int maxBlockSize, size_t midiReserveBytes = 4096) + { + signalType = signalTypeIn; + delaySamples = delaySamplesIn; + channels = numChannels; + writePosition = 0; + + if (signalType == GraphSignalType::audio && delaySamples > 0) + { + audio.setSize (channels, delaySamples + maxBlockSize + 1, false, true, false); + audio.clear(); + } + + pendingMidi.ensureSize (midiReserveBytes); + nextPendingMidi.ensureSize (midiReserveBytes); + } + + void reset() + { + writePosition = 0; + audio.clear(); + pendingMidi.clear(); + nextPendingMidi.clear(); + } + + GraphSignalType signalType = GraphSignalType::audio; + int delaySamples = 0; + int channels = 0; + int writePosition = 0; + AudioBuffer audio; + MidiBuffer pendingMidi; + MidiBuffer nextPendingMidi; + }; + + struct CompiledConnection + { + AudioGraphConnection connection; + GraphSignalType type = GraphSignalType::audio; + int sourceNodeIndex = -1; + int destinationNodeIndex = -1; + int sourceOffset = 0; + int destinationOffset = 0; + int channels = 0; + int delayLineIndex = -1; + int delaySamples = 0; + }; + + struct NodeRuntime + { + AudioGraphNodeID id; + std::shared_ptr processor; + int inputChannels = 0; + int outputChannels = 0; + int workChannels = 0; + int inputLatencySamples = 0; + int outputLatencySamples = 0; + std::vector incomingConnections; + AudioBuffer audioBuffer; + MidiBuffer midiBuffer; + }; + + struct CompiledGraph + { + CompiledGraph* nextRetired = nullptr; + + int maxBlockSize = 0; + int graphLatencySamples = 0; + int graphInputChannels = 0; + int graphOutputChannels = 0; + std::vector graphInputBusOffsets; + std::vector graphOutputBusOffsets; + std::vector nodes; + std::vector topologicalOrder; + std::vector> executionLevels; + std::vector connections; + std::vector graphOutputConnections; + std::vector delayLines; + AudioBuffer graphInputAudio; + MidiBuffer graphInputMidi; + MidiBuffer graphOutputMidi; + AudioGraphAllocationStats stats; + }; + + struct ResolvedEndpoint + { + GraphSignalType type = GraphSignalType::audio; + int channels = 0; + int offset = 0; + int nodeIndex = -1; + }; + + struct SavedNodeState + { + AudioGraphNodeID id; + AudioGraphNodeProperties properties; + MemoryBlock state; + }; + + class WorkerThread final : public Thread + { + public: + WorkerThread (Pimpl& ownerIn, int index); + ~WorkerThread() override; + + void run() override; + + private: + Pimpl& owner; + }; + + AudioGraphNodeID addNode (std::unique_ptr processor) + { + if (processor == nullptr) + return AudioGraphNodeID::invalid(); + + auto properties = makeDefaultNodeProperties (*processor); + return addNode (std::move (processor), std::move (properties)); + } + + AudioGraphNodeID addNode (std::unique_ptr processor, + AudioGraphNodeProperties properties) + { + if (processor == nullptr) + return AudioGraphNodeID::invalid(); + + const std::lock_guard lock (modelMutex); + + const auto id = AudioGraphNodeID (++nextNodeID); + if (properties.name.isEmpty()) + properties.name = processor->getName(); + + modelNodes.push_back ({ id, std::shared_ptr (std::move (processor)), std::move (properties) }); + ++modelRevision; + dirty.store (true); + return id; + } + + bool removeNode (AudioGraphNodeID nodeID) + { + if (! nodeID.isValid()) + return false; + + const std::lock_guard lock (modelMutex); + + const auto nodeIterator = std::find_if (modelNodes.begin(), modelNodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (nodeIterator == modelNodes.end()) + return false; + + modelNodes.erase (nodeIterator); + + modelConnections.erase (std::remove_if (modelConnections.begin(), modelConnections.end(), [nodeID] (const AudioGraphConnection& connection) + { + return connection.source.getNodeID() == nodeID || connection.destination.getNodeID() == nodeID; + }), + modelConnections.end()); + + ++modelRevision; + dirty.store (true); + return true; + } + + Result addConnection (const AudioGraphConnection& connection) + { + if (! connection.source.isSource()) + return Result::fail ("Audio graph connection source is not a source endpoint"); + + if (! connection.destination.isDestination()) + return Result::fail ("Audio graph connection destination is not a destination endpoint"); + + const std::lock_guard lock (modelMutex); + + if (std::find (modelConnections.begin(), modelConnections.end(), connection) != modelConnections.end()) + return Result::fail ("Audio graph connection already exists"); + + modelConnections.push_back (connection); + ++modelRevision; + dirty.store (true); + return Result::ok(); + } + + bool removeConnection (const AudioGraphConnection& connection) + { + const std::lock_guard lock (modelMutex); + const auto oldSize = modelConnections.size(); + + modelConnections.erase (std::remove (modelConnections.begin(), modelConnections.end(), connection), modelConnections.end()); + + const bool removed = modelConnections.size() != oldSize; + if (removed) + { + ++modelRevision; + dirty.store (true); + } + + return removed; + } + + std::vector getConnections() const + { + const std::lock_guard lock (modelMutex); + return modelConnections; + } + + void clear() + { + const std::lock_guard lock (modelMutex); + + modelConnections.clear(); + modelNodes.clear(); + ++modelRevision; + dirty.store (true); + } + + Result commitChanges() + { + const std::lock_guard commitLock (commitMutex); + + std::vector nodesSnapshot; + std::vector connectionsSnapshot; + uint64_t snapshotRevision = 0; + + { + const std::lock_guard lock (modelMutex); + + nodesSnapshot = modelNodes; + connectionsSnapshot = modelConnections; + snapshotRevision = modelRevision; + } + + for (auto& node : nodesSnapshot) + { + if (node.processor == nullptr) + return Result::fail ("Audio graph contains an empty node"); + + if (! node.prepared || node.preparedSampleRate != sampleRate || node.preparedBlockSize != maxBlockSize) + { + node.processor->setPlayHead (owner.getPlayHead()); + node.processor->setPlaybackConfiguration (sampleRate, maxBlockSize); + } + } + + auto compiled = std::make_unique(); + const auto result = compileGraph (*compiled, nodesSnapshot, connectionsSnapshot); + + if (! result) + return result; + + bool snapshotIsCurrent = false; + + { + const std::lock_guard lock (modelMutex); + snapshotIsCurrent = snapshotRevision == modelRevision; + + for (auto& modelNode : modelNodes) + { + const auto preparedIterator = std::find_if (nodesSnapshot.begin(), nodesSnapshot.end(), [&modelNode] (const ModelNode& preparedNode) + { + return preparedNode.id == modelNode.id; + }); + + if (preparedIterator != nodesSnapshot.end()) + { + modelNode.prepared = true; + modelNode.preparedSampleRate = sampleRate; + modelNode.preparedBlockSize = maxBlockSize; + } + } + } + + storeStats (compiled->stats); + latestLatencySamples.store (compiled->graphLatencySamples); + dirty.store (! snapshotIsCurrent); + + delete pendingPlan.exchange (compiled.release()); // TODO - make it so we don't use delete + deleteRetiredPlans(); + + return Result::ok(); + } + + void prepareToPlay (float newSampleRate, int newMaxBlockSize) + { + sampleRate = newSampleRate; + maxBlockSize = jmax (1, newMaxBlockSize); + + const auto result = commitChanges(); + jassert (result.wasOk()); + } + + void releaseResources() + { + const std::lock_guard lock (modelMutex); + + for (auto& node : modelNodes) + { + if (node.processor != nullptr && node.prepared) + node.processor->releaseResources(); + + node.prepared = false; + node.preparedSampleRate = 0.0f; + node.preparedBlockSize = 0; + } + } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) + { + ScopedNoDenormals noDenormals; + swapPendingPlan(); + + auto* graph = currentPlan; + if (graph == nullptr || owner.isSuspended()) + { + audioBuffer.clear(); + midiBuffer.clear(); + return; + } + + const int totalSamples = audioBuffer.getNumSamples(); + if (totalSamples <= 0) + { + audioBuffer.clear(); + midiBuffer.clear(); + return; + } + + graph->graphOutputMidi.clear(); + + for (int startSample = 0; startSample < totalSamples; startSample += graph->maxBlockSize) + { + const int numSamples = jmin (graph->maxBlockSize, totalSamples - startSample); + + captureGraphInput (*graph, audioBuffer, midiBuffer, startSample, numSamples); + audioBuffer.clear (startSample, numSamples); + + if (desiredWorkerThreads.load() > 0) + { + processLevels (*graph, numSamples); + } + else + { + for (const auto nodeIndex : graph->topologicalOrder) + processNode (*graph, nodeIndex, numSamples); + } + + for (const auto connectionIndex : graph->graphOutputConnections) + routeConnection (*graph, + graph->connections[static_cast (connectionIndex)], + audioBuffer, + graph->graphOutputMidi, + numSamples, + startSample, + startSample); + } + + midiBuffer.clear(); + midiBuffer.swapWith (graph->graphOutputMidi); + } + + void flush() + { + if (auto* graph = currentPlan) + { + for (auto& node : graph->nodes) + { + node.processor->flush(); + node.midiBuffer.clear(); + node.audioBuffer.clear(); + } + + for (auto& delayLine : graph->delayLines) + delayLine.reset(); + } + } + + void setNumWorkerThreads (int numThreads) + { + const int newNumThreads = jmax (0, numThreads); + desiredWorkerThreads.store (newNumThreads); + resizeWorkers (newNumThreads); + } + + void setAudioWorkgroup (AudioWorkgroup newWorkgroup) + { + const std::lock_guard lock (workgroupMutex); + workgroup = std::move (newWorkgroup); + } + + void joinWorkgroup (WorkgroupToken& token) + { + AudioWorkgroup currentWorkgroup; + + { + const std::lock_guard lock (workgroupMutex); + currentWorkgroup = workgroup; + } + + if (currentWorkgroup) + currentWorkgroup.join (token); + else + token.reset(); + } + + AudioGraphAllocationStats getAllocationStats() const noexcept + { + AudioGraphAllocationStats stats; + stats.scratchAudioBuffers = latestScratchAudioBuffers.load(); + stats.midiBuffers = latestMidiBuffers.load(); + stats.delayLines = latestDelayLines.load(); + stats.totalCompensationSamples = latestTotalCompensationSamples.load(); + stats.maxPreallocatedChannels = latestMaxPreallocatedChannels.load(); + stats.maxPreallocatedBlockSize = latestMaxPreallocatedBlockSize.load(); + return stats; + } + + AudioProcessor* getNodeProcessor (AudioGraphNodeID nodeID) const noexcept + { + const std::lock_guard lock (modelMutex); + + const auto iterator = std::find_if (modelNodes.begin(), modelNodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + return iterator != modelNodes.end() ? iterator->processor.get() : nullptr; + } + + bool setNodePosition (AudioGraphNodeID nodeID, float positionX, float positionY) + { + const std::lock_guard lock (modelMutex); + + const auto iterator = std::find_if (modelNodes.begin(), modelNodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (iterator == modelNodes.end()) + return false; + + iterator->properties.positionX = positionX; + iterator->properties.positionY = positionY; + return true; + } + + bool setNodeProperties (AudioGraphNodeID nodeID, AudioGraphNodeProperties properties) + { + const std::lock_guard lock (modelMutex); + + const auto iterator = std::find_if (modelNodes.begin(), modelNodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (iterator == modelNodes.end()) + return false; + + if (properties.name.isEmpty() && iterator->processor != nullptr) + properties.name = iterator->processor->getName(); + + iterator->properties = std::move (properties); + return true; + } + + std::optional getNodeProperties (AudioGraphNodeID nodeID) const + { + const std::lock_guard lock (modelMutex); + + const auto iterator = std::find_if (modelNodes.begin(), modelNodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (iterator == modelNodes.end()) + return std::nullopt; + + return iterator->properties; + } + + std::vector getNodeIDs() const + { + const std::lock_guard lock (modelMutex); + + std::vector result; + result.reserve (modelNodes.size()); + + for (const auto& node : modelNodes) + result.push_back (node.id); + + return result; + } + + void setNodeFactory (AudioGraphProcessor::NodeFactory factory) + { + const std::lock_guard lock (factoryMutex); + nodeFactory = std::move (factory); + } + + ResultValue> createXml() const + { + std::vector nodesSnapshot; + std::vector connectionsSnapshot; + uint64_t snapshotNextNodeID = 0; + + { + const std::lock_guard lock (modelMutex); + nodesSnapshot = modelNodes; + connectionsSnapshot = modelConnections; + snapshotNextNodeID = nextNodeID; + } + + std::vector savedNodes; + savedNodes.reserve (nodesSnapshot.size()); + + for (const auto& node : nodesSnapshot) + { + if (node.processor == nullptr) + return makeResultValueFail ("Audio graph contains an empty node"); + + MemoryBlock nodeState; + const auto result = node.processor->saveStateIntoMemory (nodeState); + + if (! result) + return makeResultValueFail ("Audio graph node state save failed: " + result.getErrorMessage()); + + savedNodes.push_back ({ node.id, node.properties, std::move (nodeState) }); + } + + auto root = std::make_unique (audioGraphStateTag); + root->setAttribute ("version", audioGraphStateVersion); + root->setAttribute ("nextNodeID", String (static_cast (snapshotNextNodeID))); + + auto* nodesElement = new XmlElement ("nodes"); + root->addChildElement (nodesElement); + + for (const auto& node : savedNodes) + { + auto* nodeElement = new XmlElement ("node"); + nodesElement->addChildElement (nodeElement); + + nodeElement->setAttribute ("id", String (static_cast (node.id.getRawID()))); + writeNodeProperties (*nodeElement, node.properties); + writeBase64Element (*nodeElement, "state", node.state); + } + + auto* connectionsElement = new XmlElement ("connections"); + root->addChildElement (connectionsElement); + + for (const auto& connection : connectionsSnapshot) + { + auto* connectionElement = new XmlElement ("connection"); + connectionsElement->addChildElement (connectionElement); + + auto* sourceElement = new XmlElement ("source"); + connectionElement->addChildElement (sourceElement); + writeEndpoint (*sourceElement, connection.source); + + auto* destinationElement = new XmlElement ("destination"); + connectionElement->addChildElement (destinationElement); + writeEndpoint (*destinationElement, connection.destination); + } + + return makeResultValueOk (std::move (root)); + } + + Result restoreFromXml (const XmlElement& xml) + { + if (! xml.hasTagName (audioGraphStateTag)) + return Result::fail ("Audio graph state has an invalid header"); + + if (xml.getIntAttribute ("version", 0) != audioGraphStateVersion) + return Result::fail ("Audio graph state has an unsupported version"); + + const auto savedNextNodeID = static_cast (xml.getStringAttribute ("nextNodeID").getLargeIntValue()); + + auto* nodesElement = xml.getChildByName ("nodes"); + if (nodesElement == nullptr) + return Result::fail ("Audio graph state is missing nodes"); + + std::vector savedNodes; + savedNodes.reserve (static_cast (nodesElement->getNumChildElements())); + + for (auto* nodeElement : nodesElement->getChildWithTagNameIterator ("node")) + { + SavedNodeState savedNode; + savedNode.id = AudioGraphNodeID (static_cast (nodeElement->getStringAttribute ("id").getLargeIntValue())); + + if (const auto result = readNodeProperties (*nodeElement, savedNode.properties); result.failed()) + return result; + + if (const auto result = readBase64Element (*nodeElement, "state", savedNode.state); result.failed()) + return result; + + savedNodes.push_back (std::move (savedNode)); + } + + auto* connectionsElement = xml.getChildByName ("connections"); + if (connectionsElement == nullptr) + return Result::fail ("Audio graph state is missing connections"); + + std::vector savedConnections; + savedConnections.reserve (static_cast (connectionsElement->getNumChildElements())); + + for (auto* connectionElement : connectionsElement->getChildWithTagNameIterator ("connection")) + { + AudioGraphEndpoint source; + AudioGraphEndpoint destination; + + auto* sourceElement = connectionElement->getChildByName ("source"); + if (sourceElement == nullptr) + return Result::fail ("Audio graph state connection is missing source endpoint"); + + auto* destinationElement = connectionElement->getChildByName ("destination"); + if (destinationElement == nullptr) + return Result::fail ("Audio graph state connection is missing destination endpoint"); + + if (const auto result = readEndpoint (*sourceElement, source); result.failed()) + return result; + + if (const auto result = readEndpoint (*destinationElement, destination); result.failed()) + return result; + + savedConnections.push_back ({ source, destination }); + } + + return restoreModel (savedNextNodeID, std::move (savedNodes), std::move (savedConnections)); + } + + Result saveStateIntoMemory (MemoryBlock& memoryBlock) + { + auto xml = createXml(); + + if (! xml) + return Result::fail (xml.getErrorMessage()); + + auto root = std::move (xml).getValue(); + MemoryOutputStream stream (memoryBlock, false); + root->writeTo (stream); + stream.flush(); + return Result::ok(); + } + + Result loadStateFromMemory (const MemoryBlock& memoryBlock) + { + MemoryInputStream stream (memoryBlock, false); + const auto xmlText = stream.readEntireStreamAsString(); + auto root = parseXML (xmlText); + + if (root == nullptr || ! root->hasTagName (audioGraphStateTag)) + return Result::fail ("Audio graph state has an invalid header"); + + return restoreFromXml (*root); + } + + Result restoreModel (uint64_t savedNextNodeID, + std::vector savedNodes, + std::vector savedConnections) + { + std::vector loadedNodes; + loadedNodes.reserve (savedNodes.size()); + + for (auto& savedNode : savedNodes) + { + auto processorResult = createProcessorForSavedNode (savedNode.properties); + + if (! processorResult) + return Result::fail (processorResult.getErrorMessage()); + + auto processor = std::move (processorResult).getValue(); + + if (processor == nullptr) + return Result::fail ("Audio graph node factory returned an empty processor"); + + const auto result = processor->loadStateFromMemory (savedNode.state); + + if (! result) + return Result::fail ("Audio graph node state load failed: " + result.getErrorMessage()); + + loadedNodes.push_back ({ savedNode.id, + std::shared_ptr (std::move (processor)), + std::move (savedNode.properties) }); + } + + std::vector previousNodes; + std::vector previousConnections; + uint64_t previousNextNodeID = 0; + bool previousDirty = false; + uint64_t highestSavedNodeID = 0; + uint64_t loadRevision = 0; + + for (const auto& savedNode : savedNodes) + highestSavedNodeID = jmax (highestSavedNodeID, savedNode.id.getRawID()); + + { + const std::lock_guard lock (modelMutex); + previousNodes = modelNodes; + previousConnections = modelConnections; + previousNextNodeID = nextNodeID; + previousDirty = dirty.load(); + modelNodes = std::move (loadedNodes); + modelConnections = std::move (savedConnections); + nextNodeID = jmax (savedNextNodeID, highestSavedNodeID); + ++modelRevision; + loadRevision = modelRevision; + dirty.store (true); + } + + const auto result = commitChanges(); + + if (! result) + { + const std::lock_guard lock (modelMutex); + + if (modelRevision == loadRevision) + { + modelNodes = std::move (previousNodes); + modelConnections = std::move (previousConnections); + nextNodeID = previousNextNodeID; + ++modelRevision; + dirty.store (previousDirty); + } + else + { + dirty.store (true); + } + } + + return result; + } + + ResultValue> createProcessorForSavedNode (const AudioGraphNodeProperties& properties) + { + AudioGraphProcessor::NodeFactory factoryCopy; + + { + const std::lock_guard lock (factoryMutex); + factoryCopy = nodeFactory; + } + + if (! factoryCopy) + return makeResultValueFail ("Audio graph node factory is not configured"); + + return factoryCopy (properties); + } + + static AudioGraphNodeProperties makeDefaultNodeProperties (const AudioProcessor& processor) + { + AudioGraphNodeProperties properties; + properties.identifier = processor.getName(); + properties.name = processor.getName(); + return properties; + } + + static void writeNodeProperties (XmlElement& element, const AudioGraphNodeProperties& properties) + { + element.setAttribute ("identifier", properties.identifier); + element.setAttribute ("name", properties.name); + element.setAttribute ("positionX", static_cast (properties.positionX)); + element.setAttribute ("positionY", static_cast (properties.positionY)); + writeBase64Element (element, "creationData", properties.creationData); + } + + static Result readNodeProperties (const XmlElement& element, AudioGraphNodeProperties& properties) + { + properties.identifier = element.getStringAttribute ("identifier"); + properties.name = element.getStringAttribute ("name"); + properties.positionX = static_cast (element.getDoubleAttribute ("positionX")); + properties.positionY = static_cast (element.getDoubleAttribute ("positionY")); + + return readBase64Element (element, "creationData", properties.creationData); + } + + static void writeBase64Element (XmlElement& parent, const char* tagName, const MemoryBlock& block) + { + auto* element = new XmlElement (tagName); + element->setAttribute ("encoding", "base64"); + element->addTextElement (Base64::toBase64 (block.getData(), block.getSize())); + parent.addChildElement (element); + } + + static Result readBase64Element (const XmlElement& parent, const char* tagName, MemoryBlock& block) + { + auto* element = parent.getChildByName (tagName); + + if (element == nullptr) + return Result::fail (String ("Audio graph state is missing ") + tagName); + + if (element->getStringAttribute ("encoding") != "base64") + return Result::fail (String ("Audio graph state has unsupported ") + tagName + " encoding"); + + MemoryOutputStream output (block, false); + const auto base64Text = element->getAllSubText().removeCharacters (" \t\r\n"); + + if (! Base64::convertFromBase64 (output, base64Text)) + return Result::fail (String ("Audio graph state has invalid ") + tagName + " data"); + + output.flush(); + return Result::ok(); + } + + static void writeEndpoint (XmlElement& element, const AudioGraphEndpoint& endpoint) + { + element.setAttribute ("kind", endpointKindToString (endpoint.getKind())); + element.setAttribute ("nodeID", String (static_cast (endpoint.getNodeID().getRawID()))); + element.setAttribute ("busIndex", endpoint.getBusIndex()); + } + + static Result readEndpoint (const XmlElement& element, AudioGraphEndpoint& endpoint) + { + auto kind = AudioGraphEndpoint::Kind::graphInput; + const auto result = endpointKindFromString (element.getStringAttribute ("kind"), kind); + + if (result.failed()) + return result; + + const auto nodeID = AudioGraphNodeID (static_cast (element.getStringAttribute ("nodeID").getLargeIntValue())); + const int busIndex = element.getIntAttribute ("busIndex"); + + switch (kind) + { + case AudioGraphEndpoint::Kind::graphInput: + if (nodeID.isValid()) + return Result::fail ("Audio graph state has an invalid graph input endpoint"); + + endpoint = AudioGraphEndpoint::graphInput (busIndex); + return Result::ok(); + + case AudioGraphEndpoint::Kind::graphOutput: + if (nodeID.isValid()) + return Result::fail ("Audio graph state has an invalid graph output endpoint"); + + endpoint = AudioGraphEndpoint::graphOutput (busIndex); + return Result::ok(); + + case AudioGraphEndpoint::Kind::nodeInput: + endpoint = AudioGraphEndpoint::nodeInput (nodeID, busIndex); + return Result::ok(); + + case AudioGraphEndpoint::Kind::nodeOutput: + endpoint = AudioGraphEndpoint::nodeOutput (nodeID, busIndex); + return Result::ok(); + } + + return Result::fail ("Audio graph state has an invalid endpoint kind"); + } + + static String endpointKindToString (AudioGraphEndpoint::Kind kind) + { + switch (kind) + { + case AudioGraphEndpoint::Kind::graphInput: + return "graphInput"; + + case AudioGraphEndpoint::Kind::graphOutput: + return "graphOutput"; + + case AudioGraphEndpoint::Kind::nodeInput: + return "nodeInput"; + + case AudioGraphEndpoint::Kind::nodeOutput: + return "nodeOutput"; + } + + return {}; + } + + static Result endpointKindFromString (const String& text, AudioGraphEndpoint::Kind& kind) + { + if (text == "graphInput") + { + kind = AudioGraphEndpoint::Kind::graphInput; + return Result::ok(); + } + + if (text == "graphOutput") + { + kind = AudioGraphEndpoint::Kind::graphOutput; + return Result::ok(); + } + + if (text == "nodeInput") + { + kind = AudioGraphEndpoint::Kind::nodeInput; + return Result::ok(); + } + + if (text == "nodeOutput") + { + kind = AudioGraphEndpoint::Kind::nodeOutput; + return Result::ok(); + } + + return Result::fail ("Audio graph state has an invalid endpoint kind"); + } + + Result compileGraph (CompiledGraph& graph, + const std::vector& nodesSnapshot, + const std::vector& connectionsSnapshot) + { + graph.maxBlockSize = maxBlockSize; + graph.graphInputChannels = getTotalAudioChannels (owner.getBusLayout().getInputBuses()); + graph.graphOutputChannels = getTotalAudioChannels (owner.getBusLayout().getOutputBuses()); + graph.graphInputBusOffsets = getAudioBusOffsets (owner.getBusLayout().getInputBuses()); + graph.graphOutputBusOffsets = getAudioBusOffsets (owner.getBusLayout().getOutputBuses()); + graph.graphInputAudio.setSize (graph.graphInputChannels, maxBlockSize, false, true, false); + graph.graphInputAudio.clear(); + + std::unordered_map nodeIndexByID; + + for (int i = 0; i < static_cast (nodesSnapshot.size()); ++i) + { + const auto& modelNode = nodesSnapshot[static_cast (i)]; + + if (modelNode.processor == nullptr) + return Result::fail ("Audio graph contains an empty node"); + + if (! nodeIndexByID.emplace (modelNode.id.getRawID(), i).second) + return Result::fail ("Audio graph contains duplicate node IDs"); + + NodeRuntime runtime; + runtime.id = modelNode.id; + runtime.processor = modelNode.processor; + runtime.inputChannels = getTotalAudioChannels (modelNode.processor->getBusLayout().getInputBuses()); + runtime.outputChannels = getTotalAudioChannels (modelNode.processor->getBusLayout().getOutputBuses()); + runtime.workChannels = jmax (runtime.inputChannels, runtime.outputChannels); + runtime.audioBuffer.setSize (runtime.workChannels, maxBlockSize, false, true, false); + runtime.audioBuffer.clear(); + graph.nodes.push_back (std::move (runtime)); + } + + std::vector> adjacency (nodesSnapshot.size()); + std::vector indegrees (nodesSnapshot.size(), 0); + + for (const auto& modelConnection : connectionsSnapshot) + { + if (std::count (connectionsSnapshot.begin(), connectionsSnapshot.end(), modelConnection) > 1) + return Result::fail ("Audio graph contains duplicate connections"); + + CompiledConnection connection; + connection.connection = modelConnection; + + const auto source = resolveEndpoint (modelConnection.source, true, nodesSnapshot, nodeIndexByID); + if (! source) + return Result::fail (source.getErrorMessage()); + + const auto destination = resolveEndpoint (modelConnection.destination, false, nodesSnapshot, nodeIndexByID); + if (! destination) + return Result::fail (destination.getErrorMessage()); + + const auto& sourceEndpoint = sourceEndpointScratch; + const auto& destinationEndpoint = destinationEndpointScratch; + + if (sourceEndpoint.type != destinationEndpoint.type) + return Result::fail ("Audio graph connection mixes audio and MIDI endpoints"); + + if (sourceEndpoint.type == GraphSignalType::audio && sourceEndpoint.channels != destinationEndpoint.channels) + return Result::fail ("Audio graph connection has incompatible channel counts"); + + connection.type = sourceEndpoint.type; + connection.sourceNodeIndex = sourceEndpoint.nodeIndex; + connection.destinationNodeIndex = destinationEndpoint.nodeIndex; + connection.sourceOffset = sourceEndpoint.offset; + connection.destinationOffset = destinationEndpoint.offset; + connection.channels = sourceEndpoint.channels; + + const int connectionIndex = static_cast (graph.connections.size()); + graph.connections.push_back (connection); + + if (connection.destinationNodeIndex >= 0) + { + graph.nodes[static_cast (connection.destinationNodeIndex)].incomingConnections.push_back (connectionIndex); + + if (connection.sourceNodeIndex >= 0) + { + adjacency[static_cast (connection.sourceNodeIndex)].push_back (connection.destinationNodeIndex); + ++indegrees[static_cast (connection.destinationNodeIndex)]; + } + } + else + { + graph.graphOutputConnections.push_back (connectionIndex); + } + } + + int numMidiConnections = 0; + for (const auto& conn : graph.connections) + if (conn.type == GraphSignalType::midi) + ++numMidiConnections; + + const size_t midiReserveBytes = static_cast (jmax (1, numMidiConnections + 1)) * 4096; + + graph.graphInputMidi.ensureSize (midiReserveBytes); + graph.graphOutputMidi.ensureSize (midiReserveBytes); + + for (auto& node : graph.nodes) + node.midiBuffer.ensureSize (midiReserveBytes); + + auto topologicalResult = buildTopologicalOrder (graph, adjacency, indegrees); + if (! topologicalResult) + return topologicalResult; + + buildExecutionLevels (graph); + computeLatenciesAndDelays (graph, midiReserveBytes); + fillStats (graph); + + return Result::ok(); + } + + Result resolveEndpoint (const AudioGraphEndpoint& endpoint, + bool source, + const std::vector& nodesSnapshot, + const std::unordered_map& nodeIndexByID) + { + auto& result = source ? sourceEndpointScratch : destinationEndpointScratch; + result = {}; + + if (source && ! endpoint.isSource()) + return Result::fail ("Audio graph endpoint is not a source"); + + if (! source && ! endpoint.isDestination()) + return Result::fail ("Audio graph endpoint is not a destination"); + + Span buses; + std::vector offsets; + + switch (endpoint.getKind()) + { + case AudioGraphEndpoint::Kind::graphInput: + buses = owner.getBusLayout().getInputBuses(); + offsets = getAudioBusOffsets (buses); + break; + + case AudioGraphEndpoint::Kind::graphOutput: + buses = owner.getBusLayout().getOutputBuses(); + offsets = getAudioBusOffsets (buses); + break; + + case AudioGraphEndpoint::Kind::nodeInput: + case AudioGraphEndpoint::Kind::nodeOutput: + { + const auto iterator = nodeIndexByID.find (endpoint.getNodeID().getRawID()); + if (iterator == nodeIndexByID.end()) + return Result::fail ("Audio graph connection references a missing node"); + + result.nodeIndex = iterator->second; + const auto& processor = *nodesSnapshot[static_cast (result.nodeIndex)].processor; + + buses = endpoint.getKind() == AudioGraphEndpoint::Kind::nodeInput + ? processor.getBusLayout().getInputBuses() + : processor.getBusLayout().getOutputBuses(); + offsets = getAudioBusOffsets (buses); + break; + } + } + + if (! isValidBusIndex (buses, endpoint.getBusIndex())) + return Result::fail ("Audio graph connection references an invalid bus index"); + + const auto& bus = buses[static_cast (endpoint.getBusIndex())]; + result.type = toSignalType (bus.getType()); + result.channels = bus.getType() == AudioBus::Type::Audio ? bus.getNumChannels() : 0; + result.offset = bus.getType() == AudioBus::Type::Audio ? offsets[static_cast (endpoint.getBusIndex())] : 0; + return Result::ok(); + } + + Result buildTopologicalOrder (CompiledGraph& graph, + const std::vector>& adjacency, + std::vector indegrees) + { + std::vector ready; + + for (int i = 0; i < static_cast (indegrees.size()); ++i) + if (indegrees[static_cast (i)] == 0) + ready.push_back (i); + + while (! ready.empty()) + { + const int nodeIndex = ready.front(); + ready.erase (ready.begin()); + graph.topologicalOrder.push_back (nodeIndex); + + for (const auto destination : adjacency[static_cast (nodeIndex)]) + { + auto& indegree = indegrees[static_cast (destination)]; + --indegree; + + if (indegree == 0) + ready.push_back (destination); + } + } + + if (graph.topologicalOrder.size() != graph.nodes.size()) + return Result::fail ("Audio graph contains a cycle"); + + return Result::ok(); + } + + void buildExecutionLevels (CompiledGraph& graph) + { + std::vector nodeLevels (graph.nodes.size(), 0); + + for (const auto nodeIndex : graph.topologicalOrder) + { + int level = 0; + const auto& node = graph.nodes[static_cast (nodeIndex)]; + + for (const auto connectionIndex : node.incomingConnections) + { + const auto& connection = graph.connections[static_cast (connectionIndex)]; + if (connection.sourceNodeIndex >= 0) + level = jmax (level, nodeLevels[static_cast (connection.sourceNodeIndex)] + 1); + } + + nodeLevels[static_cast (nodeIndex)] = level; + + if (level >= static_cast (graph.executionLevels.size())) + graph.executionLevels.resize (static_cast (level + 1)); + + graph.executionLevels[static_cast (level)].push_back (nodeIndex); + } + } + + int getSourceLatency (const CompiledGraph& graph, const CompiledConnection& connection) const + { + if (connection.sourceNodeIndex < 0) + return 0; + + return graph.nodes[static_cast (connection.sourceNodeIndex)].outputLatencySamples; + } + + void computeLatenciesAndDelays (CompiledGraph& graph, size_t midiReserveBytes) + { + for (const auto nodeIndex : graph.topologicalOrder) + { + auto& node = graph.nodes[static_cast (nodeIndex)]; + + for (const auto connectionIndex : node.incomingConnections) + node.inputLatencySamples = jmax (node.inputLatencySamples, + getSourceLatency (graph, graph.connections[static_cast (connectionIndex)])); + + node.outputLatencySamples = node.inputLatencySamples + jmax (0, node.processor->getLatencySamples()); + } + + for (const auto connectionIndex : graph.graphOutputConnections) + graph.graphLatencySamples = jmax (graph.graphLatencySamples, + getSourceLatency (graph, graph.connections[static_cast (connectionIndex)])); + + for (auto& connection : graph.connections) + { + const int destinationLatency = connection.destinationNodeIndex >= 0 + ? graph.nodes[static_cast (connection.destinationNodeIndex)].inputLatencySamples + : graph.graphLatencySamples; + + connection.delaySamples = jmax (0, destinationLatency - getSourceLatency (graph, connection)); + + if (connection.delaySamples > 0) + { + DelayLine delayLine; + delayLine.initialise (connection.type, connection.channels, connection.delaySamples, graph.maxBlockSize, midiReserveBytes); + connection.delayLineIndex = static_cast (graph.delayLines.size()); + graph.delayLines.push_back (std::move (delayLine)); + } + } + } + + void fillStats (CompiledGraph& graph) + { + graph.stats.scratchAudioBuffers = static_cast (graph.nodes.size()); + graph.stats.midiBuffers = static_cast (graph.nodes.size()); + graph.stats.delayLines = static_cast (graph.delayLines.size()); + graph.stats.totalCompensationSamples = graph.graphLatencySamples; + graph.stats.maxPreallocatedBlockSize = graph.maxBlockSize; + graph.stats.maxPreallocatedChannels = jmax (graph.graphInputChannels, graph.graphOutputChannels); + + for (const auto& node : graph.nodes) + graph.stats.maxPreallocatedChannels = jmax (graph.stats.maxPreallocatedChannels, node.workChannels); + } + + void storeStats (const AudioGraphAllocationStats& stats) noexcept + { + latestScratchAudioBuffers.store (stats.scratchAudioBuffers); + latestMidiBuffers.store (stats.midiBuffers); + latestDelayLines.store (stats.delayLines); + latestTotalCompensationSamples.store (stats.totalCompensationSamples); + latestMaxPreallocatedChannels.store (stats.maxPreallocatedChannels); + latestMaxPreallocatedBlockSize.store (stats.maxPreallocatedBlockSize); + } + + void captureGraphInput (CompiledGraph& graph, + AudioBuffer& audioBuffer, + MidiBuffer& midiBuffer, + int startSample, + int numSamples) + { + graph.graphInputAudio.clear (0, numSamples); + + const int numChannels = jmin (graph.graphInputChannels, audioBuffer.getNumChannels()); + for (int channel = 0; channel < numChannels; ++channel) + graph.graphInputAudio.copyFrom (channel, 0, audioBuffer, channel, startSample, numSamples); + + graph.graphInputMidi.clear(); + graph.graphInputMidi.addEvents (midiBuffer, startSample, numSamples, -startSample); + } + + void processNode (CompiledGraph& graph, int nodeIndex, int numSamples) + { + auto& node = graph.nodes[static_cast (nodeIndex)]; + + node.audioBuffer.setSize (node.workChannels, numSamples, false, false, true); + node.audioBuffer.clear (0, numSamples); + node.midiBuffer.clear(); + + for (const auto connectionIndex : node.incomingConnections) + routeConnection (graph, graph.connections[static_cast (connectionIndex)], node.audioBuffer, node.midiBuffer, numSamples); + + node.processor->processBlock (node.audioBuffer, node.midiBuffer); + } + + void processLevels (CompiledGraph& graph, int numSamples) + { + for (auto& level : graph.executionLevels) + { + if (level.empty()) + continue; + + const auto generation = workGeneration.load (std::memory_order_relaxed) + 1; + + activeGraph.store (&graph, std::memory_order_relaxed); + activeLevel.store (&level, std::memory_order_relaxed); + activeNumSamples.store (numSamples, std::memory_order_relaxed); + nextJobIndex.store (0, std::memory_order_relaxed); + remainingJobs.store (static_cast (level.size()), std::memory_order_release); + activeGeneration.store (generation, std::memory_order_release); + workGeneration.store (generation, std::memory_order_release); + workerReadyEvent.reset(); + workerReadyEvent.signal(); + + drainActiveJobs (generation); + + while (remainingJobs.load (std::memory_order_acquire) > 0) + ; + + workerReadyEvent.reset(); + } + + activeGraph.store (nullptr, std::memory_order_relaxed); + activeLevel.store (nullptr, std::memory_order_relaxed); + activeNumSamples.store (0, std::memory_order_relaxed); + activeGeneration.store (0, std::memory_order_release); + } + + void drainActiveJobs (int generation) + { + if (activeGeneration.load (std::memory_order_acquire) != generation) + return; + + auto* graph = activeGraph.load (std::memory_order_relaxed); + auto* level = activeLevel.load (std::memory_order_relaxed); + + if (graph == nullptr || level == nullptr) + return; + + const int numSamples = activeNumSamples.load (std::memory_order_relaxed); + + for (;;) + { + const int jobIndex = nextJobIndex.fetch_add (1); + + if (jobIndex >= static_cast (level->size())) + break; + + processNode (*graph, (*level)[static_cast (jobIndex)], numSamples); + + remainingJobs.fetch_sub (1, std::memory_order_acq_rel); + } + } + + void routeConnection (CompiledGraph& graph, + CompiledConnection& connection, + AudioBuffer& destinationAudio, + MidiBuffer& destinationMidi, + int numSamples, + int destinationStartSample = 0, + int midiSampleOffset = 0) + { + if (connection.type == GraphSignalType::audio) + { + const auto& sourceAudio = getSourceAudioBuffer (graph, connection); + + if (connection.delayLineIndex >= 0) + routeDelayedAudio (graph.delayLines[static_cast (connection.delayLineIndex)], sourceAudio, connection, destinationAudio, numSamples, destinationStartSample); + else + routeAudio (sourceAudio, connection, destinationAudio, numSamples, destinationStartSample); + + return; + } + + const auto& sourceMidi = getSourceMidiBuffer (graph, connection); + + if (connection.delayLineIndex >= 0) + routeDelayedMidi (graph.delayLines[static_cast (connection.delayLineIndex)], sourceMidi, destinationMidi, numSamples, midiSampleOffset); + else + destinationMidi.addEvents (sourceMidi, 0, numSamples, midiSampleOffset); + } + + const AudioBuffer& getSourceAudioBuffer (CompiledGraph& graph, const CompiledConnection& connection) const + { + if (connection.sourceNodeIndex < 0) + return graph.graphInputAudio; + + return graph.nodes[static_cast (connection.sourceNodeIndex)].audioBuffer; + } + + const MidiBuffer& getSourceMidiBuffer (CompiledGraph& graph, const CompiledConnection& connection) const + { + if (connection.sourceNodeIndex < 0) + return graph.graphInputMidi; + + return graph.nodes[static_cast (connection.sourceNodeIndex)].midiBuffer; + } + + void routeAudio (const AudioBuffer& sourceAudio, + const CompiledConnection& connection, + AudioBuffer& destinationAudio, + int numSamples, + int destinationStartSample) + { + const int channels = jmin (connection.channels, + jmax (0, sourceAudio.getNumChannels() - connection.sourceOffset), + jmax (0, destinationAudio.getNumChannels() - connection.destinationOffset)); + + for (int channel = 0; channel < channels; ++channel) + destinationAudio.addFrom (connection.destinationOffset + channel, + destinationStartSample, + sourceAudio, + connection.sourceOffset + channel, + 0, + numSamples); + } + + void routeDelayedAudio (DelayLine& delayLine, + const AudioBuffer& sourceAudio, + const CompiledConnection& connection, + AudioBuffer& destinationAudio, + int numSamples, + int destinationStartSample) + { + if (delayLine.delaySamples <= 0) + { + routeAudio (sourceAudio, connection, destinationAudio, numSamples, destinationStartSample); + return; + } + + const int channels = jmin (connection.channels, + jmax (0, sourceAudio.getNumChannels() - connection.sourceOffset), + jmax (0, destinationAudio.getNumChannels() - connection.destinationOffset), + delayLine.audio.getNumChannels()); + const int ringSize = delayLine.audio.getNumSamples(); + + for (int sample = 0; sample < numSamples; ++sample) + { + const int readPosition = (delayLine.writePosition + ringSize - delayLine.delaySamples) % ringSize; + + for (int channel = 0; channel < channels; ++channel) + { + const float delayedSample = delayLine.audio.getReadPointer (channel)[readPosition]; + destinationAudio.addFrom (connection.destinationOffset + channel, destinationStartSample + sample, &delayedSample, 1); + + delayLine.audio.getWritePointer (channel)[delayLine.writePosition] = + sourceAudio.getReadPointer (connection.sourceOffset + channel)[sample]; + } + + delayLine.writePosition = (delayLine.writePosition + 1) % ringSize; + } + } + + void routeDelayedMidi (DelayLine& delayLine, + const MidiBuffer& sourceMidi, + MidiBuffer& destinationMidi, + int numSamples, + int midiSampleOffset) + { + delayLine.nextPendingMidi.clear(); + + for (const auto metadata : delayLine.pendingMidi) + { + if (metadata.samplePosition < numSamples) + destinationMidi.addEvent (metadata.data, metadata.numBytes, midiSampleOffset + metadata.samplePosition); + else + delayLine.nextPendingMidi.addEvent (metadata.data, metadata.numBytes, metadata.samplePosition - numSamples); + } + + for (const auto metadata : sourceMidi) + { + if (metadata.samplePosition < 0 || metadata.samplePosition >= numSamples) + continue; + + const int delayedPosition = metadata.samplePosition + delayLine.delaySamples; + + if (delayedPosition < numSamples) + destinationMidi.addEvent (metadata.data, metadata.numBytes, midiSampleOffset + delayedPosition); + else + delayLine.nextPendingMidi.addEvent (metadata.data, metadata.numBytes, delayedPosition - numSamples); + } + + delayLine.pendingMidi.swapWith (delayLine.nextPendingMidi); + } + + void swapPendingPlan() + { + if (auto* nextPlan = pendingPlan.exchange (nullptr)) + { + retirePlan (currentPlan); + currentPlan = nextPlan; + } + } + + void retirePlan (CompiledGraph* plan) noexcept + { + if (plan == nullptr) + return; + + auto* head = retiredPlans.load(); + + do + { + plan->nextRetired = head; + } while (! retiredPlans.compare_exchange_weak (head, plan)); + } + + void deleteRetiredPlans() + { + auto* plan = retiredPlans.exchange (nullptr); + + while (plan != nullptr) + { + auto* next = plan->nextRetired; + delete plan; + plan = next; + } + } + + void resizeWorkers (int newNumThreads) + { + while (static_cast (workers.size()) > newNumThreads) + { + workers.back()->signalThreadShouldExit(); + workerReadyEvent.signal(); + workers.pop_back(); + } + + if (! workers.empty()) + workerReadyEvent.reset(); + + while (static_cast (workers.size()) < newNumThreads) + { + auto worker = std::make_unique (*this, static_cast (workers.size())); + worker->startThread (Thread::Priority::highest); + workers.push_back (std::move (worker)); + } + } + + AudioGraphProcessor& owner; + mutable std::mutex commitMutex; + mutable std::mutex modelMutex; + mutable std::mutex factoryMutex; + mutable std::mutex workgroupMutex; + std::vector modelNodes; + std::vector modelConnections; + uint64_t nextNodeID = 0; + uint64_t modelRevision = 0; + std::atomic dirty { true }; + std::atomic desiredWorkerThreads { 0 }; + std::atomic latestLatencySamples { 0 }; + std::atomic latestScratchAudioBuffers { 0 }; + std::atomic latestMidiBuffers { 0 }; + std::atomic latestDelayLines { 0 }; + std::atomic latestTotalCompensationSamples { 0 }; + std::atomic latestMaxPreallocatedChannels { 0 }; + std::atomic latestMaxPreallocatedBlockSize { 0 }; + AudioGraphProcessor::NodeFactory nodeFactory; + AudioWorkgroup workgroup; + float sampleRate = 44100.0f; + int maxBlockSize = 1024; + std::atomic pendingPlan { nullptr }; + std::atomic retiredPlans { nullptr }; + CompiledGraph* currentPlan = nullptr; + ResolvedEndpoint sourceEndpointScratch; + ResolvedEndpoint destinationEndpointScratch; + std::vector> workers; + WaitableEvent workerReadyEvent { true }; + std::atomic workGeneration { 0 }; + std::atomic nextJobIndex { 0 }; + std::atomic remainingJobs { 0 }; + std::atomic activeGraph { nullptr }; + std::atomic*> activeLevel { nullptr }; + std::atomic activeNumSamples { 0 }; + std::atomic activeGeneration { 0 }; +}; + +//============================================================================== +AudioGraphProcessor::Pimpl::WorkerThread::WorkerThread (Pimpl& ownerIn, int index) + : Thread ("Audio Graph Worker " + String (index + 1)) + , owner (ownerIn) +{ +} + +AudioGraphProcessor::Pimpl::WorkerThread::~WorkerThread() +{ + stopThread (2000); +} + +void AudioGraphProcessor::Pimpl::WorkerThread::run() +{ + int lastGeneration = 0; + WorkgroupToken workgroupToken; + + while (! threadShouldExit()) + { + owner.workerReadyEvent.wait (-1.0); + + if (threadShouldExit()) + break; + + const int generation = owner.workGeneration.load (std::memory_order_acquire); + if (generation == lastGeneration) + continue; + + lastGeneration = generation; + + ScopedNoDenormals noDenormals; + owner.joinWorkgroup (workgroupToken); + owner.drainActiveJobs (generation); + } +} + +//============================================================================== +AudioBusLayout AudioGraphProcessor::createDefaultBusLayout() +{ + return AudioBusLayout ({ AudioBus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2) }, + { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) }); +} + +AudioGraphProcessor::AudioGraphProcessor (AudioBusLayout busLayout) + : AudioProcessor ("Audio Graph", std::move (busLayout)) + , pimpl (std::make_unique (*this)) +{ +} + +AudioGraphProcessor::~AudioGraphProcessor() = default; + +AudioGraphNodeID AudioGraphProcessor::addNode (std::unique_ptr processor) +{ + return pimpl->addNode (std::move (processor)); +} + +AudioGraphNodeID AudioGraphProcessor::addNode (std::unique_ptr processor, + AudioGraphNodeProperties properties) +{ + return pimpl->addNode (std::move (processor), std::move (properties)); +} + +bool AudioGraphProcessor::removeNode (AudioGraphNodeID nodeID) +{ + return pimpl->removeNode (nodeID); +} + +Result AudioGraphProcessor::addConnection (const AudioGraphConnection& connection) +{ + return pimpl->addConnection (connection); +} + +bool AudioGraphProcessor::removeConnection (const AudioGraphConnection& connection) +{ + return pimpl->removeConnection (connection); +} + +std::vector AudioGraphProcessor::getConnections() const +{ + return pimpl->getConnections(); +} + +void AudioGraphProcessor::clear() +{ + pimpl->clear(); +} + +Result AudioGraphProcessor::commitChanges() +{ + return pimpl->commitChanges(); +} + +void AudioGraphProcessor::setNumWorkerThreads (int numThreads) +{ + pimpl->setNumWorkerThreads (numThreads); +} + +int AudioGraphProcessor::getNumWorkerThreads() const noexcept +{ + return pimpl->desiredWorkerThreads.load(); +} + +void AudioGraphProcessor::setAudioWorkgroup (AudioWorkgroup workgroup) +{ + pimpl->setAudioWorkgroup (std::move (workgroup)); +} + +AudioGraphAllocationStats AudioGraphProcessor::getAllocationStats() const noexcept +{ + return pimpl->getAllocationStats(); +} + +bool AudioGraphProcessor::hasUncommittedChanges() const noexcept +{ + return pimpl->dirty.load(); +} + +AudioProcessor* AudioGraphProcessor::getNodeProcessor (AudioGraphNodeID nodeID) const noexcept +{ + return pimpl->getNodeProcessor (nodeID); +} + +bool AudioGraphProcessor::setNodePosition (AudioGraphNodeID nodeID, float positionX, float positionY) +{ + return pimpl->setNodePosition (nodeID, positionX, positionY); +} + +bool AudioGraphProcessor::setNodeProperties (AudioGraphNodeID nodeID, AudioGraphNodeProperties properties) +{ + return pimpl->setNodeProperties (nodeID, std::move (properties)); +} + +std::optional AudioGraphProcessor::getNodeProperties (AudioGraphNodeID nodeID) const +{ + return pimpl->getNodeProperties (nodeID); +} + +std::vector AudioGraphProcessor::getNodeIDs() const +{ + return pimpl->getNodeIDs(); +} + +void AudioGraphProcessor::setNodeFactory (NodeFactory factory) +{ + pimpl->setNodeFactory (std::move (factory)); +} + +std::unique_ptr AudioGraphProcessor::createXml() const +{ + auto result = pimpl->createXml(); + + if (! result) + return nullptr; + + return std::move (result).getValue(); +} + +Result AudioGraphProcessor::restoreFromXml (const XmlElement& xml) +{ + return pimpl->restoreFromXml (xml); +} + +void AudioGraphProcessor::prepareToPlay (float sampleRate, int maxBlockSize) +{ + pimpl->prepareToPlay (sampleRate, maxBlockSize); +} + +void AudioGraphProcessor::releaseResources() +{ + pimpl->releaseResources(); +} + +void AudioGraphProcessor::processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) +{ + pimpl->processBlock (audioBuffer, midiBuffer); +} + +void AudioGraphProcessor::flush() +{ + pimpl->flush(); +} + +int AudioGraphProcessor::getLatencySamples() +{ + return pimpl->latestLatencySamples.load(); +} + +Result AudioGraphProcessor::loadStateFromMemory (const MemoryBlock& memoryBlock) +{ + return pimpl->loadStateFromMemory (memoryBlock); +} + +Result AudioGraphProcessor::saveStateIntoMemory (MemoryBlock& memoryBlock) +{ + return pimpl->saveStateIntoMemory (memoryBlock); +} + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h new file mode 100644 index 000000000..831c2bb68 --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h @@ -0,0 +1,154 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + An AudioProcessor that owns and executes an acyclic graph of AudioProcessor nodes. + + Edits are made to a control-thread graph model. commitChanges() validates the + model, prepares newly compiled nodes for the current playback configuration, and + publishes an immutable processing plan. processBlock() only swaps pending plans at + block boundaries and keeps retired plans alive until a later control-thread commit + or destruction. +*/ +class YUP_API AudioGraphProcessor final : public AudioProcessor +{ +public: + /** Creates a processor for a saved node. */ + using NodeFactory = std::function> (const AudioGraphNodeProperties&)>; + + /** Creates the default stereo graph bus layout. */ + static AudioBusLayout createDefaultBusLayout(); + + //============================================================================== + /** Constructs an audio graph with the supplied graph input/output bus layout. */ + explicit AudioGraphProcessor (AudioBusLayout busLayout = createDefaultBusLayout()); + + /** Destructs the graph and releases all owned processors. */ + ~AudioGraphProcessor() override; + + //============================================================================== + /** Adds a processor node and returns its stable node identifier. */ + AudioGraphNodeID addNode (std::unique_ptr processor); + + /** Adds a processor node with persistent metadata and returns its stable node identifier. */ + AudioGraphNodeID addNode (std::unique_ptr processor, + AudioGraphNodeProperties properties); + + /** Removes a processor node and all connections that mention it. */ + bool removeNode (AudioGraphNodeID nodeID); + + /** Adds a connection to the control-thread graph model. */ + Result addConnection (const AudioGraphConnection& connection); + + /** Removes a connection from the control-thread graph model. */ + bool removeConnection (const AudioGraphConnection& connection); + + /** Returns a snapshot of the current control-thread graph model connections. */ + std::vector getConnections() const; + + /** Removes all graph nodes and connections. */ + void clear(); + + //============================================================================== + /** Validates, compiles, and publishes the current graph model. */ + Result commitChanges(); + + /** Returns true when graph edits have not yet been committed. */ + bool hasUncommittedChanges() const noexcept; + + //============================================================================== + /** Sets the desired worker thread count for future processing blocks. */ + void setNumWorkerThreads (int numThreads); + + /** Returns the desired worker thread count. */ + int getNumWorkerThreads() const noexcept; + + /** Supplies the audio workgroup that worker threads should join when supported. */ + void setAudioWorkgroup (AudioWorkgroup workgroup); + + /** Returns diagnostics for the last successfully compiled graph. */ + AudioGraphAllocationStats getAllocationStats() const noexcept; + + /** Returns the processor for a node, or nullptr when the node is not present. */ + AudioProcessor* getNodeProcessor (AudioGraphNodeID nodeID) const noexcept; + + //============================================================================== + /** Updates the saved canvas position for a node. */ + bool setNodePosition (AudioGraphNodeID nodeID, float positionX, float positionY); + + /** Updates the persistent metadata for a node. */ + bool setNodeProperties (AudioGraphNodeID nodeID, AudioGraphNodeProperties properties); + + /** Returns persistent metadata for a node, or nullopt when the node is not present. */ + std::optional getNodeProperties (AudioGraphNodeID nodeID) const; + + /** Returns the identifiers of all nodes currently in the control-thread graph model. */ + std::vector getNodeIDs() const; + + /** Sets the factory used to recreate processor nodes during state loading. */ + void setNodeFactory (NodeFactory factory); + + //============================================================================== + /** Creates an XML representation of the current graph, including node state. */ + std::unique_ptr createXml() const; + + /** Restores the graph from XML previously created by createXml(). */ + Result restoreFromXml (const XmlElement& xml); + + //============================================================================== + // AudioProcessor + void prepareToPlay (float sampleRate, int maxBlockSize) override; + void releaseResources() override; + void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override; + void flush() override; + + int getLatencySamples() override; + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock& memoryBlock) override; + + Result saveStateIntoMemory (MemoryBlock& memoryBlock) override; + + bool hasEditor() const override { return false; } + + AudioProcessorEditor* createEditor() override { return nullptr; } + +private: + class Pimpl; + std::unique_ptr pimpl; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioGraphProcessor) +}; + +} // namespace yup diff --git a/modules/yup_audio_graph/yup_audio_graph.cpp b/modules/yup_audio_graph/yup_audio_graph.cpp new file mode 100644 index 000000000..5f3576af7 --- /dev/null +++ b/modules/yup_audio_graph/yup_audio_graph.cpp @@ -0,0 +1,34 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#ifdef YUP_AUDIO_GRAPH_H_INCLUDED +/* When you add this cpp file to your project, you mustn't include it in a file where you've + already included any other headers - just put it inside a file on its own, possibly with your config + flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix + header files that the compiler may be using. +*/ +#error "Incorrect use of YUP cpp file" +#endif + +#include "yup_audio_graph.h" + +//============================================================================== +#include "graph/yup_AudioGraphProcessor.cpp" diff --git a/modules/yup_audio_graph/yup_audio_graph.h b/modules/yup_audio_graph/yup_audio_graph.h new file mode 100644 index 000000000..6e82336e9 --- /dev/null +++ b/modules/yup_audio_graph/yup_audio_graph.h @@ -0,0 +1,66 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: yup_audio_graph + vendor: yup + version: 1.0.0 + name: YUP Audio Graph + description: AudioProcessor-based audio and MIDI processing graph. + website: https://github.com/kunitoki/yup + license: ISC + minimumCppStandard: 17 + + dependencies: yup_audio_processors + searchpaths: native + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once +#define YUP_AUDIO_GRAPH_H_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +//============================================================================== +#include "graph/yup_AudioGraphNodeID.h" +#include "graph/yup_AudioGraphEndpoint.h" +#include "graph/yup_AudioGraphConnection.h" +#include "graph/yup_AudioGraphAllocationStats.h" +#include "graph/yup_AudioGraphNodeProperties.h" +#include "graph/yup_AudioGraphProcessor.h" diff --git a/modules/yup_audio_graph/yup_audio_graph.mm b/modules/yup_audio_graph/yup_audio_graph.mm new file mode 100644 index 000000000..1b195b8cd --- /dev/null +++ b/modules/yup_audio_graph/yup_audio_graph.mm @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_audio_graph.cpp" diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp new file mode 100644 index 000000000..07de1dfec --- /dev/null +++ b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp @@ -0,0 +1,981 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +namespace +{ +AudioGraphNodeView* findNodeViewForEventSource (Component* source) noexcept +{ + if (source == nullptr) + return nullptr; + + if (auto* nodeView = dynamic_cast (source)) + return nodeView; + + return source->getParentComponentWithType(); +} +} // namespace + +//============================================================================== +const Identifier AudioGraphComponent::Style::backgroundColorId ("audioGraphBackground"); +const Identifier AudioGraphComponent::Style::gridColorId ("audioGraphGrid"); + +//============================================================================== +AudioGraphComponent::AudioGraphComponent (std::shared_ptr graphIn) + : graph (std::move (graphIn)) +{ + setOpaque (true); + setWantsKeyboardFocus (true); + setWantsMouseEvents (true, true); + enableRenderingUnclipped (true); +} + +AudioGraphComponent::~AudioGraphComponent() +{ + for (auto& node : nodes) + { + if (node.view != nullptr) + node.view->removeMouseListener (this); + } +} + +//============================================================================== +void AudioGraphComponent::addNodeView (AudioGraphNodeID nodeID, std::unique_ptr view, Point initialCanvasPosition) +{ + if (view == nullptr) + return; + + removeNodeView (nodeID); + + view->setViewScale (zoom); + view->addMouseListener (this); + addAndMakeVisible (*view); + + nodes.push_back ({ nodeID, std::move (view), initialCanvasPosition, NodeItem::Kind::processor }); + updateNodeBounds(); + repaint(); +} + +void AudioGraphComponent::setGraphInputView (std::unique_ptr view, Point initialCanvasPosition) +{ + if (view == nullptr) + return; + + auto* item = findNodeItemByKind (NodeItem::Kind::graphInput); + if (item != nullptr) + { + if (item->view != nullptr) + { + item->view->removeMouseListener (this); + removeChildComponent (item->view.get()); + } + + item->view = std::move (view); + item->canvasPosition = initialCanvasPosition; + } + else + { + nodes.push_back ({ AudioGraphNodeID::invalid(), std::move (view), initialCanvasPosition, NodeItem::Kind::graphInput }); + item = &nodes.back(); + } + + item->view->setViewScale (zoom); + item->view->addMouseListener (this); + addAndMakeVisible (*item->view); + updateNodeBounds(); + repaint(); +} + +void AudioGraphComponent::setGraphOutputView (std::unique_ptr view, Point initialCanvasPosition) +{ + if (view == nullptr) + return; + + auto* item = findNodeItemByKind (NodeItem::Kind::graphOutput); + if (item != nullptr) + { + if (item->view != nullptr) + { + item->view->removeMouseListener (this); + removeChildComponent (item->view.get()); + } + + item->view = std::move (view); + item->canvasPosition = initialCanvasPosition; + } + else + { + nodes.push_back ({ AudioGraphNodeID::invalid(), std::move (view), initialCanvasPosition, NodeItem::Kind::graphOutput }); + item = &nodes.back(); + } + + item->view->setViewScale (zoom); + item->view->addMouseListener (this); + addAndMakeVisible (*item->view); + updateNodeBounds(); + repaint(); +} + +void AudioGraphComponent::removeNodeView (AudioGraphNodeID nodeID) +{ + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const NodeItem& item) + { + return item.kind == NodeItem::Kind::processor && item.nodeID == nodeID; + }); + + if (iterator == nodes.end()) + return; + + if (iterator->view != nullptr) + { + iterator->view->removeMouseListener (this); + removeChildComponent (iterator->view.get()); + } + + nodes.erase (iterator); + repaint(); +} + +AudioGraphNodeView* AudioGraphComponent::getNodeView (AudioGraphNodeID nodeID) const noexcept +{ + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const NodeItem& item) + { + return item.kind == NodeItem::Kind::processor && item.nodeID == nodeID; + }); + + return iterator != nodes.end() ? iterator->view.get() : nullptr; +} + +//============================================================================== +void AudioGraphComponent::setZoom (float newZoom) +{ + const auto clampedZoom = jlimit (minZoom, maxZoom, newZoom); + if (zoom == clampedZoom) + return; + + needsInitialReset = false; + needsInitialZoomToFit = false; + zoom = clampedZoom; + updateNodeBounds(); + repaint(); +} + +void AudioGraphComponent::setMinZoom (float newMinZoom) +{ + const auto clampedMinZoom = jmax (0.001f, newMinZoom); + if (minZoom == clampedMinZoom) + return; + + minZoom = clampedMinZoom; + + if (maxZoom < minZoom) + maxZoom = minZoom; + + setZoom (zoom); +} + +void AudioGraphComponent::setMaxZoom (float newMaxZoom) +{ + const auto clampedMaxZoom = jmax (0.001f, newMaxZoom); + if (maxZoom == clampedMaxZoom) + return; + + maxZoom = clampedMaxZoom; + + if (minZoom > maxZoom) + minZoom = maxZoom; + + setZoom (zoom); +} + +void AudioGraphComponent::setWireHitDistance (float newWireHitDistance) +{ + wireHitDistance = jmax (0.0f, newWireHitDistance); +} + +void AudioGraphComponent::setDragWireThreshold (float newDragWireThreshold) +{ + dragWireThreshold = jmax (0.0f, newDragWireThreshold); +} + +void AudioGraphComponent::setCanvasOffset (Point offset) +{ + needsInitialReset = false; + needsInitialZoomToFit = false; + canvasOffset = offset; + updateNodeBounds(); + repaint(); +} + +void AudioGraphComponent::resetView() +{ + if (getWidth() <= 0.0f || getHeight() <= 0.0f) + { + needsInitialReset = true; + needsInitialZoomToFit = false; + return; + } + + needsInitialReset = false; + needsInitialZoomToFit = false; + zoom = jlimit (minZoom, maxZoom, 1.0f); + + centerViewOnNodes(); +} + +void AudioGraphComponent::centerViewOnNodes() +{ + if (getWidth() <= 0.0f || getHeight() <= 0.0f) + { + needsInitialReset = true; + needsInitialZoomToFit = false; + return; + } + + needsInitialReset = false; + needsInitialZoomToFit = false; + + const auto bounds = getNodesCanvasBounds(); + if (! bounds.has_value()) + { + canvasOffset = getLocalBounds().getCenter(); + updateNodeBounds(); + repaint(); + return; + } + + canvasOffset = getLocalBounds().getCenter() - (bounds->getCenter() * zoom); + updateNodeBounds(); + repaint(); +} + +void AudioGraphComponent::zoomToFitNodes (float padding, float maximumZoom) +{ + if (getWidth() <= 0.0f || getHeight() <= 0.0f) + { + needsInitialReset = false; + needsInitialZoomToFit = true; + pendingZoomToFitPadding = padding; + pendingZoomToFitMaximumZoom = maximumZoom; + return; + } + + needsInitialReset = false; + needsInitialZoomToFit = false; + const auto upperZoom = jlimit (minZoom, maxZoom, maximumZoom); + + const auto bounds = getNodesCanvasBounds(); + if (! bounds.has_value()) + { + zoom = jlimit (minZoom, upperZoom, 1.0f); + canvasOffset = getLocalBounds().getCenter(); + updateNodeBounds(); + repaint(); + return; + } + + const auto safePadding = jmax (0.0f, padding); + const auto availableWidth = jmax (1.0f, static_cast (getWidth()) - (safePadding * 2.0f)); + const auto availableHeight = jmax (1.0f, static_cast (getHeight()) - (safePadding * 2.0f)); + const auto fitZoom = jmin (availableWidth / jmax (1.0f, bounds->getWidth()), + availableHeight / jmax (1.0f, bounds->getHeight())); + + zoom = jlimit (minZoom, upperZoom, fitZoom); + canvasOffset = getLocalBounds().getCenter() - (bounds->getCenter() * zoom); + updateNodeBounds(); + repaint(); +} + +Point AudioGraphComponent::canvasToScreen (Point canvasPos) const +{ + return (canvasPos * zoom) + canvasOffset; +} + +Point AudioGraphComponent::screenToCanvas (Point screenPos) const +{ + return (screenPos - canvasOffset) / zoom; +} + +void AudioGraphComponent::addListener (Listener* listener) +{ + listeners.add (listener); +} + +void AudioGraphComponent::removeListener (Listener* listener) +{ + listeners.remove (listener); +} + +//============================================================================== +void AudioGraphComponent::resized() +{ + if (needsInitialZoomToFit) + zoomToFitNodes (pendingZoomToFitPadding, pendingZoomToFitMaximumZoom); + else if (needsInitialReset || canvasOffset.isOrigin()) + resetView(); + else + updateNodeBounds(); +} + +void AudioGraphComponent::paint (Graphics& g) +{ + if (auto style = ApplicationTheme::findComponentStyle (*this)) + style->paint (g, *ApplicationTheme::getGlobalTheme(), *this); +} + +//============================================================================== +void AudioGraphComponent::mouseDown (const MouseEvent& event) +{ + takeKeyboardFocus(); + + const auto screenPos = eventPositionInThisComponent (event); + mouseDownScreen = screenPos; + lastMouseScreen = screenPos; + + if (event.getButtons() == MouseEvent::rightButton) + { + if (auto endpoint = hitTestEndpoint (screenPos)) + { + removeConnectionsForEndpoint (endpoint->endpoint); + return; + } + + if (auto connection = hitTestConnection (screenPos)) + { + removeConnectionAndCommit (*connection); + return; + } + + for (const auto& node : nodes) + { + if (node.view == nullptr || ! node.nodeID.isValid()) + continue; + + const auto localPos = node.view->getLocalPoint (this, screenPos); + + if (node.view->getLocalBounds().contains (localPos)) + { + if (onNodeContextMenu != nullptr) + onNodeContextMenu (node.nodeID, screenToCanvas (screenPos)); + + return; + } + } + + if (onCanvasContextMenu != nullptr) + onCanvasContextMenu (screenToCanvas (screenPos)); + + return; + } + + if (spacebarDown || event.getButtons() == MouseEvent::middleButton) + { + interaction = Interaction::panningCanvas; + panStartOffset = canvasOffset; + setMouseCursor (MouseCursor::ResizeAll); + return; + } + + if (auto* clickedNodeView = findNodeViewForEventSource (event.getSourceComponent())) + clickedNodeView->toFront (true); + + if (auto endpoint = hitTestEndpoint (screenPos)) + { + if (interaction == Interaction::armedPort && activeEndpoint.has_value()) + { + if (tryConnect (*activeEndpoint, endpoint->endpoint)) + { + interaction = Interaction::idle; + activeEndpoint.reset(); + } + else + { + activeEndpoint = endpoint->endpoint; + interaction = Interaction::armedPort; + } + } + else + { + activeEndpoint = endpoint->endpoint; + interaction = Interaction::armedPort; + } + + pendingWireEnd = screenPos; + repaint(); + return; + } + + if (interaction == Interaction::armedPort) + { + interaction = Interaction::idle; + activeEndpoint.reset(); + repaint(); + return; + } + + auto* source = event.getSourceComponent(); + if (auto* nodeView = dynamic_cast (source)) + { + if (auto* item = findNodeItemForView (nodeView)) + { + draggedNodeIndex = static_cast (item - nodes.data()); + dragStartCanvasMouse = screenToCanvas (screenPos); + dragStartNodePosition = item->canvasPosition; + interaction = Interaction::draggingNode; + } + } +} + +void AudioGraphComponent::mouseDrag (const MouseEvent& event) +{ + const auto screenPos = eventPositionInThisComponent (event); + lastMouseScreen = screenPos; + + switch (interaction) + { + case Interaction::panningCanvas: + setCanvasOffset (panStartOffset + (screenPos - mouseDownScreen)); + break; + + case Interaction::draggingNode: + { + if (isPositiveAndBelow (draggedNodeIndex, static_cast (nodes.size()))) + { + nodes[static_cast (draggedNodeIndex)].canvasPosition = dragStartNodePosition + (screenToCanvas (screenPos) - dragStartCanvasMouse); + updateNodeBounds(); + repaint(); + } + + break; + } + + case Interaction::armedPort: + if (mouseDownScreen.distanceTo (screenPos) > dragWireThreshold) + interaction = Interaction::draggingWire; + + pendingWireEnd = screenPos; + repaint(); + break; + + case Interaction::draggingWire: + pendingWireEnd = screenPos; + repaint(); + break; + + case Interaction::idle: + break; + } +} + +void AudioGraphComponent::mouseUp (const MouseEvent& event) +{ + const auto screenPos = eventPositionInThisComponent (event); + lastMouseScreen = screenPos; + + switch (interaction) + { + case Interaction::draggingWire: + if (activeEndpoint.has_value()) + { + if (auto endpoint = hitTestEndpoint (screenPos)) + tryConnect (*activeEndpoint, endpoint->endpoint); + } + + activeEndpoint.reset(); + interaction = Interaction::idle; + repaint(); + break; + + case Interaction::draggingNode: + { + if (isPositiveAndBelow (draggedNodeIndex, static_cast (nodes.size()))) + { + const auto& item = nodes[static_cast (draggedNodeIndex)]; + + if (item.kind == NodeItem::Kind::processor) + listeners.call ([&] (Listener& listener) + { + listener.nodeViewMoved (item.nodeID, item.canvasPosition); + }); + } + + interaction = Interaction::idle; + draggedNodeIndex = -1; + break; + } + + case Interaction::panningCanvas: + interaction = Interaction::idle; + setMouseCursor (MouseCursor::Default); + break; + + case Interaction::armedPort: + case Interaction::idle: + break; + } +} + +void AudioGraphComponent::mouseDoubleClick (const MouseEvent& event) +{ + if (onNodeDoubleClicked == nullptr) + return; + + const auto screenPos = eventPositionInThisComponent (event); + + for (const auto& node : nodes) + { + if (node.view == nullptr || ! node.nodeID.isValid()) + continue; + + const auto localPos = node.view->getLocalPoint (this, screenPos); + + if (node.view->getLocalBounds().contains (localPos)) + { + onNodeDoubleClicked (node.nodeID); + return; + } + } +} + +void AudioGraphComponent::mouseWheel (const MouseEvent& event, const MouseWheelData& wheelData) +{ + const auto screenPos = eventPositionInThisComponent (event); + const auto canvasPos = screenToCanvas (screenPos); + const auto zoomFactor = std::pow (1.12f, wheelData.getDeltaY()); + const auto newZoom = jlimit (minZoom, maxZoom, zoom * zoomFactor); + + if (newZoom == zoom) + return; + + needsInitialReset = false; + needsInitialZoomToFit = false; + zoom = newZoom; + canvasOffset = screenPos - (canvasPos * zoom); + updateNodeBounds(); + repaint(); +} + +void AudioGraphComponent::keyDown (const KeyPress& keys, const Point&) +{ + if (keys.getKey() == KeyPress::spaceKey) + { + spacebarDown = true; + setMouseCursor (MouseCursor::ResizeAll); + return; + } + + if (keys.getKey() == KeyPress::textFKey || keys.getKey() == KeyPress::homeKey) + { + zoomToFitNodes(); + return; + } + + if (keys.getKey() == KeyPress::number0Key) + resetView(); +} + +void AudioGraphComponent::keyUp (const KeyPress& keys, const Point&) +{ + if (keys.getKey() == KeyPress::spaceKey) + { + spacebarDown = false; + setMouseCursor (MouseCursor::Default); + } +} + +//============================================================================== +void AudioGraphComponent::updateNodeBounds() +{ + for (auto& node : nodes) + { + if (node.view == nullptr) + continue; + + node.view->setViewScale (zoom); + + const auto canvasBounds = getNodeCanvasBounds (node); + const auto screenTopLeft = canvasToScreen (canvasBounds.getPosition()); + node.view->setBounds ({ screenTopLeft, canvasBounds.getSize() * zoom }); + } +} + +Rectangle AudioGraphComponent::getNodeCanvasBounds (const NodeItem& item) const +{ + if (item.view == nullptr) + return {}; + + return { item.canvasPosition, + static_cast (item.view->getPreferredWidth()), + static_cast (item.view->getPreferredHeight()) }; +} + +std::optional> AudioGraphComponent::getNodesCanvasBounds() const +{ + std::optional> result; + + for (const auto& node : nodes) + { + if (node.view == nullptr) + continue; + + const auto bounds = getNodeCanvasBounds (node); + if (bounds.isEmpty()) + continue; + + if (! result.has_value()) + { + result = bounds; + continue; + } + + const auto left = jmin (result->getLeft(), bounds.getLeft()); + const auto top = jmin (result->getTop(), bounds.getTop()); + const auto right = jmax (result->getRight(), bounds.getRight()); + const auto bottom = jmax (result->getBottom(), bounds.getBottom()); + result = Rectangle { left, top, right - left, bottom - top }; + } + + return result; +} + +Point AudioGraphComponent::eventPositionInThisComponent (const MouseEvent& event) const +{ + if (event.getSourceComponent() == this || event.getSourceComponent() == nullptr) + return event.getPosition(); + + return getLocalPoint (event.getSourceComponent(), event.getPosition()); +} + +AudioGraphComponent::NodeItem* AudioGraphComponent::findNodeItemForView (const AudioGraphNodeView* view) noexcept +{ + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [view] (const NodeItem& item) + { + return item.view.get() == view; + }); + + return iterator != nodes.end() ? &*iterator : nullptr; +} + +const AudioGraphComponent::NodeItem* AudioGraphComponent::findNodeItemForView (const AudioGraphNodeView* view) const noexcept +{ + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [view] (const NodeItem& item) + { + return item.view.get() == view; + }); + + return iterator != nodes.end() ? &*iterator : nullptr; +} + +AudioGraphComponent::NodeItem* AudioGraphComponent::findNodeItemByKind (NodeItem::Kind kind) noexcept +{ + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [kind] (const NodeItem& item) + { + return item.kind == kind; + }); + + return iterator != nodes.end() ? &*iterator : nullptr; +} + +const AudioGraphComponent::NodeItem* AudioGraphComponent::findNodeItemByKind (NodeItem::Kind kind) const noexcept +{ + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [kind] (const NodeItem& item) + { + return item.kind == kind; + }); + + return iterator != nodes.end() ? &*iterator : nullptr; +} + +std::optional AudioGraphComponent::hitTestEndpoint (Point screenPos) const +{ + for (const auto& node : nodes) + { + if (node.view == nullptr) + continue; + + const auto localPos = node.view->getLocalPoint (this, screenPos); + if (auto port = node.view->hitTestPort (localPos)) + { + AudioGraphEndpoint endpoint; + + if (node.kind == NodeItem::Kind::graphInput) + endpoint = AudioGraphEndpoint::graphInput (port->busIndex); + else if (node.kind == NodeItem::Kind::graphOutput) + endpoint = AudioGraphEndpoint::graphOutput (port->busIndex); + else + endpoint = port->isInput + ? AudioGraphEndpoint::nodeInput (node.nodeID, port->busIndex) + : AudioGraphEndpoint::nodeOutput (node.nodeID, port->busIndex); + + return EndpointHit { endpoint, *port, node.view.get() }; + } + } + + return {}; +} + +std::optional AudioGraphComponent::hitTestConnection (Point screenPos) const +{ + if (graph == nullptr) + return {}; + + for (const auto& connection : graph->getConnections()) + { + const auto start = getEndpointScreenPosition (connection.source); + const auto end = getEndpointScreenPosition (connection.destination); + const auto controlOffset = jmax (60.0f, std::abs (end.getX() - start.getX()) * 0.5f); + const auto cp1 = Point { start.getX() + controlOffset, start.getY() }; + const auto cp2 = Point { end.getX() - controlOffset, end.getY() }; + + auto previous = start; + const auto approximateLength = jmax (40.0f, start.distanceTo (end)); + const auto numSteps = jmax (8, roundToInt (approximateLength / 10.0f)); + + for (int i = 1; i <= numSteps; ++i) + { + const auto t = static_cast (i) / static_cast (numSteps); + const auto current = cubicPoint (start, cp1, cp2, end, t); + + if (distanceToSegment (screenPos, previous, current) <= wireHitDistance) + return connection; + + previous = current; + } + } + + return {}; +} + +Point AudioGraphComponent::getEndpointScreenPosition (const AudioGraphEndpoint& endpoint) const +{ + if (endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput || endpoint.getKind() == AudioGraphEndpoint::Kind::graphOutput) + { + if (const auto* item = findNodeItemByKind (endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput ? NodeItem::Kind::graphInput : NodeItem::Kind::graphOutput)) + { + if (item->view != nullptr) + { + const auto local = endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput + ? item->view->getOutputPortCenter (endpoint.getBusIndex()) + : item->view->getInputPortCenter (endpoint.getBusIndex()); + + return getLocalPoint (item->view.get(), local); + } + } + + auto left = 0.0f; + auto right = 720.0f; + auto top = 96.0f; + + if (! nodes.empty()) + { + left = std::numeric_limits::max(); + right = std::numeric_limits::lowest(); + top = std::numeric_limits::max(); + + for (const auto& node : nodes) + { + const auto bounds = getNodeCanvasBounds (node); + left = jmin (left, bounds.getLeft()); + right = jmax (right, bounds.getRight()); + top = jmin (top, bounds.getTop()); + } + } + + const auto x = endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput ? left - 96.0f : right + 96.0f; + const auto y = top + 60.0f + static_cast (endpoint.getBusIndex()) * 36.0f; + return canvasToScreen ({ x, y }); + } + + if (auto* view = getNodeView (endpoint.getNodeID())) + { + const auto local = endpoint.getKind() == AudioGraphEndpoint::Kind::nodeInput + ? view->getInputPortCenter (endpoint.getBusIndex()) + : view->getOutputPortCenter (endpoint.getBusIndex()); + + return getLocalPoint (view, local); + } + + return {}; +} + +Color AudioGraphComponent::getEndpointColor (const AudioGraphEndpoint& endpoint) const +{ + if (endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput || endpoint.getKind() == AudioGraphEndpoint::Kind::graphOutput) + { + if (const auto* item = findNodeItemByKind (endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput ? NodeItem::Kind::graphInput : NodeItem::Kind::graphOutput)) + { + if (item->view != nullptr) + { + return endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput + ? item->view->getOutputPortInfo (endpoint.getBusIndex()).color + : item->view->getInputPortInfo (endpoint.getBusIndex()).color; + } + } + } + + if (auto* view = getNodeView (endpoint.getNodeID())) + { + if (endpoint.getKind() == AudioGraphEndpoint::Kind::nodeInput) + return view->getInputPortInfo (endpoint.getBusIndex()).color; + + if (endpoint.getKind() == AudioGraphEndpoint::Kind::nodeOutput) + return view->getOutputPortInfo (endpoint.getBusIndex()).color; + } + + return AudioGraphNodeView::getPortKindColor (AudioGraphNodeView::PortKind::audio); +} + +bool AudioGraphComponent::isPendingWireVisible() const noexcept +{ + return activeEndpoint.has_value() && (interaction == Interaction::armedPort || interaction == Interaction::draggingWire); +} + +std::optional AudioGraphComponent::getPendingWireEndpoint() const +{ + return activeEndpoint; +} + +Point AudioGraphComponent::getPendingWireEndPosition() const noexcept +{ + return interaction == Interaction::draggingWire ? pendingWireEnd : lastMouseScreen; +} + +bool AudioGraphComponent::tryConnect (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) +{ + if (graph == nullptr || ! isCompatiblePair (first, second)) + return false; + + const auto connection = makeConnection (first, second); + + if (graph->addConnection (connection).failed()) + return false; + + if (graph->commitChanges().failed()) + { + graph->removeConnection (connection); + graph->commitChanges(); + return false; + } + + listeners.call ([&] (Listener& listener) + { + listener.connectionAdded (connection); + }); + repaint(); + return true; +} + +bool AudioGraphComponent::removeConnectionAndCommit (const AudioGraphConnection& connection) +{ + if (graph == nullptr || ! graph->removeConnection (connection)) + return false; + + if (graph->commitChanges().failed()) + { + graph->addConnection (connection); + graph->commitChanges(); + return false; + } + + listeners.call ([&] (Listener& listener) + { + listener.connectionRemoved (connection); + }); + repaint(); + return true; +} + +void AudioGraphComponent::removeConnectionsForEndpoint (const AudioGraphEndpoint& endpoint) +{ + if (graph == nullptr) + return; + + const auto connections = graph->getConnections(); + std::vector removedConnections; + + for (const auto& connection : connections) + { + if (connection.source == endpoint || connection.destination == endpoint) + { + if (graph->removeConnection (connection)) + removedConnections.push_back (connection); + } + } + + if (removedConnections.empty()) + return; + + if (graph->commitChanges().failed()) + { + for (const auto& connection : removedConnections) + graph->addConnection (connection); + + graph->commitChanges(); + return; + } + + for (const auto& connection : removedConnections) + { + listeners.call ([&] (Listener& listener) + { + listener.connectionRemoved (connection); + }); + } + + repaint(); +} + +bool AudioGraphComponent::isCompatiblePair (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept +{ + return (first.isSource() && second.isDestination()) || (second.isSource() && first.isDestination()); +} + +AudioGraphConnection AudioGraphComponent::makeConnection (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept +{ + return first.isSource() ? AudioGraphConnection { first, second } + : AudioGraphConnection { second, first }; +} + +Point AudioGraphComponent::cubicPoint (Point p0, Point p1, Point p2, Point p3, float t) const noexcept +{ + const auto u = 1.0f - t; + return (p0 * (u * u * u)) + + (p1 * (3.0f * u * u * t)) + + (p2 * (3.0f * u * t * t)) + + (p3 * (t * t * t)); +} + +float AudioGraphComponent::distanceToSegment (Point point, Point start, Point end) const noexcept +{ + const auto segment = end - start; + const auto lengthSquared = segment.distanceToSquared ({}); + + if (lengthSquared <= 0.0f) + return point.distanceTo (start); + + const auto pointVector = point - start; + const auto t = jlimit (0.0f, 1.0f, ((pointVector.getX() * segment.getX()) + (pointVector.getY() * segment.getY())) / lengthSquared); + return point.distanceTo (start + (segment * t)); +} + +} // namespace yup diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h new file mode 100644 index 000000000..68bb858da --- /dev/null +++ b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h @@ -0,0 +1,283 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + Zoomable and pannable editor canvas for an AudioGraphProcessor. + + The component owns only the visual node views. The AudioGraphProcessor remains + the source of truth for nodes and connections. + + @see AudioGraphNodeView, AudioGraphProcessor +*/ +class YUP_API AudioGraphComponent : public Component +{ +public: + /** Style identifiers for theme customization. */ + struct Style + { + /** Canvas background color. */ + static const Identifier backgroundColorId; + + /** Grid dot color. */ + static const Identifier gridColorId; + }; + + /** Receives notifications for graph editor gestures that committed changes. */ + struct Listener + { + virtual ~Listener() = default; + + /** Called after a connection has been added and committed. */ + virtual void connectionAdded (const AudioGraphConnection&) {} + + /** Called after a connection has been removed and committed. */ + virtual void connectionRemoved (const AudioGraphConnection&) {} + + /** Called when a node view is released after dragging. */ + virtual void nodeViewMoved (AudioGraphNodeID, Point newCanvasPos) {} + }; + + /** Creates an editor for graph. */ + explicit AudioGraphComponent (std::shared_ptr graph); + + /** Destructor. */ + ~AudioGraphComponent() override; + + /** Adds or replaces the view for a node at an initial canvas position. */ + void addNodeView (AudioGraphNodeID nodeID, + std::unique_ptr view, + Point initialCanvasPosition); + + /** Adds or replaces the view that represents the graph input buses. */ + void setGraphInputView (std::unique_ptr view, + Point initialCanvasPosition); + + /** Adds or replaces the view that represents the graph output buses. */ + void setGraphOutputView (std::unique_ptr view, + Point initialCanvasPosition); + + /** Removes the view for a node. */ + void removeNodeView (AudioGraphNodeID nodeID); + + /** Returns the view for a node, or nullptr. */ + AudioGraphNodeView* getNodeView (AudioGraphNodeID nodeID) const noexcept; + + /** @internal Returns the graph processor currently being edited. */ + const AudioGraphProcessor* getGraphProcessor() const noexcept { return graph.get(); } + + /** Sets the zoom factor, clamped to [getMinZoom(), getMaxZoom()]. */ + void setZoom (float zoom); + + /** Returns the zoom factor. */ + float getZoom() const noexcept { return zoom; } + + /** Sets the minimum zoom factor. */ + void setMinZoom (float newMinZoom); + + /** Returns the minimum zoom factor. */ + float getMinZoom() const noexcept { return minZoom; } + + /** Sets the maximum zoom factor. */ + void setMaxZoom (float newMaxZoom); + + /** Returns the maximum zoom factor. */ + float getMaxZoom() const noexcept { return maxZoom; } + + /** Sets the screen-space hit distance used for selecting connection wires. */ + void setWireHitDistance (float newWireHitDistance); + + /** Returns the screen-space hit distance used for selecting connection wires. */ + float getWireHitDistance() const noexcept { return wireHitDistance; } + + /** Sets the drag distance needed before an armed port starts dragging a wire. */ + void setDragWireThreshold (float newDragWireThreshold); + + /** Returns the drag distance needed before an armed port starts dragging a wire. */ + float getDragWireThreshold() const noexcept { return dragWireThreshold; } + + /** Sets the canvas-to-screen offset. */ + void setCanvasOffset (Point offset); + + /** Returns the canvas-to-screen offset. */ + Point getCanvasOffset() const noexcept { return canvasOffset; } + + /** Resets zoom and centers the current node views. */ + void resetView(); + + /** Centers the current node views without changing the zoom factor. */ + void centerViewOnNodes(); + + /** Zooms and centers the current node views so they fit in the viewport. */ + void zoomToFitNodes (float padding = 48.0f, float maximumZoom = 1.0f); + + /** Converts a canvas-space point to component-local screen space. */ + Point canvasToScreen (Point canvasPos) const; + + /** Converts component-local screen space to canvas space. */ + Point screenToCanvas (Point screenPos) const; + + /** @internal Returns the screen position for an endpoint. */ + Point getEndpointScreenPosition (const AudioGraphEndpoint& endpoint) const; + + /** @internal Returns the display color for an endpoint. */ + Color getEndpointColor (const AudioGraphEndpoint& endpoint) const; + + /** @internal Returns true when a pending connection wire should be painted. */ + bool isPendingWireVisible() const noexcept; + + /** @internal Returns the endpoint that started the pending connection wire. */ + std::optional getPendingWireEndpoint() const; + + /** @internal Returns the current screen endpoint for the pending connection wire. */ + Point getPendingWireEndPosition() const noexcept; + + /** Adds a listener. */ + void addListener (Listener* listener); + + /** Removes a listener. */ + void removeListener (Listener* listener); + + /** + Called when the user right-clicks on an empty area of the canvas. + canvasPos is the click position in canvas coordinates. + */ + std::function canvasPos)> onCanvasContextMenu; + + /** + Called when the user right-clicks on a node body (not a port or wire). + nodeID identifies the node; canvasPos is the position in canvas coordinates. + */ + std::function canvasPos)> onNodeContextMenu; + + /** + Called when the user double-clicks on a node body. + nodeID identifies the clicked node. + */ + std::function onNodeDoubleClicked; + + /** @internal */ + void resized() override; + /** @internal */ + void paint (Graphics& g) override; + /** @internal */ + void mouseDown (const MouseEvent& event) override; + /** @internal */ + void mouseDrag (const MouseEvent& event) override; + /** @internal */ + void mouseUp (const MouseEvent& event) override; + /** @internal */ + void mouseDoubleClick (const MouseEvent& event) override; + /** @internal */ + void mouseWheel (const MouseEvent& event, const MouseWheelData& wheelData) override; + /** @internal */ + void keyDown (const KeyPress& keys, const Point& position) override; + /** @internal */ + void keyUp (const KeyPress& keys, const Point& position) override; + +private: + struct NodeItem + { + enum class Kind + { + processor, + graphInput, + graphOutput + }; + + AudioGraphNodeID nodeID; + std::unique_ptr view; + Point canvasPosition; + Kind kind = Kind::processor; + }; + + struct EndpointHit + { + AudioGraphEndpoint endpoint; + AudioGraphNodeView::PortHit port; + AudioGraphNodeView* view = nullptr; + }; + + enum class Interaction + { + idle, + panningCanvas, + draggingNode, + draggingWire, + armedPort + }; + + void updateNodeBounds(); + Rectangle getNodeCanvasBounds (const NodeItem& item) const; + std::optional> getNodesCanvasBounds() const; + + Point eventPositionInThisComponent (const MouseEvent& event) const; + NodeItem* findNodeItemForView (const AudioGraphNodeView* view) noexcept; + const NodeItem* findNodeItemForView (const AudioGraphNodeView* view) const noexcept; + NodeItem* findNodeItemByKind (NodeItem::Kind kind) noexcept; + const NodeItem* findNodeItemByKind (NodeItem::Kind kind) const noexcept; + std::optional hitTestEndpoint (Point screenPos) const; + std::optional hitTestConnection (Point screenPos) const; + + bool tryConnect (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second); + bool removeConnectionAndCommit (const AudioGraphConnection& connection); + void removeConnectionsForEndpoint (const AudioGraphEndpoint& endpoint); + + bool isCompatiblePair (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept; + AudioGraphConnection makeConnection (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept; + + Point cubicPoint (Point p0, Point p1, Point p2, Point p3, float t) const noexcept; + float distanceToSegment (Point point, Point start, Point end) const noexcept; + + std::shared_ptr graph; + std::vector nodes; + ListenerList listeners; + + Interaction interaction = Interaction::idle; + float minZoom = 0.1f; + float maxZoom = 4.0f; + float wireHitDistance = 8.0f; + float dragWireThreshold = 4.0f; + float zoom = 1.0f; + Point canvasOffset { 0.0f, 0.0f }; + bool spacebarDown = false; + bool needsInitialReset = true; + bool needsInitialZoomToFit = false; + float pendingZoomToFitPadding = 48.0f; + float pendingZoomToFitMaximumZoom = 1.0f; + + Point mouseDownScreen; + Point lastMouseScreen; + Point panStartOffset; + Point dragStartCanvasMouse; + Point dragStartNodePosition; + int draggedNodeIndex = -1; + + std::optional activeEndpoint; + Point pendingWireEnd; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioGraphComponent) +}; + +} // namespace yup diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.cpp b/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.cpp new file mode 100644 index 000000000..2d1d89ead --- /dev/null +++ b/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.cpp @@ -0,0 +1,189 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +namespace +{ +constexpr float baseHeaderHeight = 32.0f; +constexpr float basePortRowHeight = 24.0f; +constexpr float basePortTopPadding = 8.0f; +constexpr float baseParameterRowHeight = 25.0f; +constexpr float basePortRadius = 6.0f; +constexpr float baseContentHeight = 8.0f; + +} // namespace + +//============================================================================== +const Identifier AudioGraphNodeView::Style::shadowColorId ("audioGraphNodeShadow"); +const Identifier AudioGraphNodeView::Style::accentBackgroundColorId ("audioGraphNodeAccentBackground"); +const Identifier AudioGraphNodeView::Style::bodyBackgroundColorId ("audioGraphNodeBodyBackground"); +const Identifier AudioGraphNodeView::Style::headerBackgroundColorId ("audioGraphNodeHeaderBackground"); +const Identifier AudioGraphNodeView::Style::textColorId ("audioGraphNodeText"); +const Identifier AudioGraphNodeView::Style::subtitleTextColorId ("audioGraphNodeSubtitleText"); +const Identifier AudioGraphNodeView::Style::parameterBackgroundColorId ("audioGraphNodeParameterBackground"); +const Identifier AudioGraphNodeView::Style::parameterValueBackgroundColorId ("audioGraphNodeParameterValueBackground"); +const Identifier AudioGraphNodeView::Style::portHoleColorId ("audioGraphNodePortHole"); + +//============================================================================== +AudioGraphNodeView::AudioGraphNodeView (AudioGraphNodeID nodeIDIn) + : nodeID (nodeIDIn) +{ + enableRenderingUnclipped (true); + setWantsMouseEvents (true, true); +} + +AudioGraphNodeView::~AudioGraphNodeView() = default; + +//============================================================================== +Color AudioGraphNodeView::getNodeColor() const +{ + return Colors::dimgray; +} + +String AudioGraphNodeView::getNodeSubtitle() const +{ + return {}; +} + +AudioGraphNodeView::PortInfo AudioGraphNodeView::getInputPortInfo (int busIndex) const +{ + return { String ("in ") + String (busIndex + 1), getPortKindColor (PortKind::audio), PortKind::audio }; +} + +AudioGraphNodeView::PortInfo AudioGraphNodeView::getOutputPortInfo (int busIndex) const +{ + return { String ("out ") + String (busIndex + 1), getPortKindColor (PortKind::audio), PortKind::audio }; +} + +int AudioGraphNodeView::getNumParameterRows() const +{ + return 0; +} + +AudioGraphNodeView::ParameterInfo AudioGraphNodeView::getParameterInfo (int parameterIndex) const +{ + return { String ("param ") + String (parameterIndex + 1), {}, getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; +} + +void AudioGraphNodeView::paintNodeContent (Graphics&, Rectangle) const +{ +} + +int AudioGraphNodeView::getPreferredWidth() const +{ + return 140; +} + +int AudioGraphNodeView::getPreferredHeight() const +{ + // TODO - this needs to be moved to the style somehow + const auto portRows = jmax (1, jmax (getNumInputPorts(), getNumOutputPorts())); + return roundToInt (baseHeaderHeight + + baseContentHeight + + (static_cast (getNumParameterRows()) * baseParameterRowHeight) + + basePortTopPadding + + (static_cast (portRows) * basePortRowHeight) + + 10.0f); +} + +Point AudioGraphNodeView::getInputPortCenter (int busIndex) const +{ + return getPortCenter (busIndex, true); +} + +Point AudioGraphNodeView::getOutputPortCenter (int busIndex) const +{ + return getPortCenter (busIndex, false); +} + +float AudioGraphNodeView::getPortRadius() const +{ + return basePortRadius * viewScale; +} + +std::optional AudioGraphNodeView::hitTestPort (Point localPos) const +{ + const auto radiusSquared = getPortRadius() * getPortRadius(); + + for (int i = 0; i < getNumInputPorts(); ++i) + { + if (localPos.distanceToSquared (getInputPortCenter (i)) <= radiusSquared) + return PortHit { i, true }; + } + + for (int i = 0; i < getNumOutputPorts(); ++i) + { + if (localPos.distanceToSquared (getOutputPortCenter (i)) <= radiusSquared) + return PortHit { i, false }; + } + + return {}; +} + +Color AudioGraphNodeView::getPortKindColor (PortKind kind) +{ + switch (kind) + { + case PortKind::audio: + return Color (0xffffc43b); + case PortKind::midi: + return Color (0xffff4d67); + case PortKind::parameter: + return Color (0xff2f8cff); + } + + return Colors::dimgray; +} + +void AudioGraphNodeView::setViewScale (float newScale) +{ + viewScale = jlimit (0.1f, 4.0f, newScale); +} + +//============================================================================== +void AudioGraphNodeView::paint (Graphics& g) +{ + if (auto style = ApplicationTheme::findComponentStyle (*this)) + style->paint (g, *ApplicationTheme::getGlobalTheme(), *this); +} + +Point AudioGraphNodeView::getPortCenter (int busIndex, bool isInput) const +{ + const auto portCount = isInput ? getNumInputPorts() : getNumOutputPorts(); + if (busIndex < 0 || busIndex >= portCount) + return {}; + + const auto portArea = getPortArea(); + const auto y = portArea.getY() + basePortTopPadding * viewScale + (static_cast (busIndex) + 0.5f) * basePortRowHeight * viewScale; + const auto x = isInput ? portArea.getX() : portArea.getRight(); + return { x, y }; +} + +Rectangle AudioGraphNodeView::getPortArea() const +{ + const auto bodyBounds = getLocalBounds().reduced (getPortRadius() * 0.5f + 2.0f * viewScale, 2.0f * viewScale); + const auto y = bodyBounds.getY() + (baseHeaderHeight + baseContentHeight + 4.0f + static_cast (getNumParameterRows()) * baseParameterRowHeight) * viewScale; + return { bodyBounds.getX(), y, bodyBounds.getWidth(), jmax (0.0f, bodyBounds.getBottom() - y - 6.0f * viewScale) }; +} + +} // namespace yup diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.h b/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.h new file mode 100644 index 000000000..37d1b2e94 --- /dev/null +++ b/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.h @@ -0,0 +1,181 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** + A visual node embedded by AudioGraphComponent. + + AudioGraphNodeView is a regular Component, so subclasses can add child controls, + meters, or custom painting while the graph canvas handles placement, panning, + zooming, and connection gestures. + + @see AudioGraphComponent, AudioGraphProcessor +*/ +class YUP_API AudioGraphNodeView : public Component +{ +public: + /** Style identifiers for theme customization. */ + struct Style + { + static const Identifier shadowColorId; + static const Identifier accentBackgroundColorId; + static const Identifier bodyBackgroundColorId; + static const Identifier headerBackgroundColorId; + static const Identifier textColorId; + static const Identifier subtitleTextColorId; + static const Identifier parameterBackgroundColorId; + static const Identifier parameterValueBackgroundColorId; + static const Identifier portHoleColorId; + }; + + /** Semantic signal type for ports and future connection routing. */ + enum class PortKind + { + audio, + midi, + parameter + }; + + /** Describes a single input or output port. */ + struct PortInfo + { + /** Display name shown next to the port. */ + String name; + + /** Port fill color. */ + Color color; + + /** Signal type carried by the port. */ + PortKind kind = PortKind::audio; + }; + + /** Describes an inline parameter row inside a node. */ + struct ParameterInfo + { + /** Parameter display name. */ + String name; + + /** Current parameter value display text. */ + String value; + + /** Accent color for the value and optional modulation port. */ + Color color; + + /** Normalized value in [0, 1], or a negative value to hide the bar. */ + float normalizedValue = -1.0f; + + /** Signal type used for this row's future modulation port. */ + PortKind kind = PortKind::parameter; + }; + + /** Identifies the port hit by the user. */ + struct PortHit + { + /** Bus index on the node processor. */ + int busIndex = -1; + + /** True for input ports, false for output ports. */ + bool isInput = false; + }; + + /** Creates a node view for a graph node identifier. */ + explicit AudioGraphNodeView (AudioGraphNodeID nodeID); + + /** Destructor. */ + ~AudioGraphNodeView() override; + + /** Returns the graph node identifier represented by this view. */ + AudioGraphNodeID getNodeID() const noexcept { return nodeID; } + + /** Returns the title rendered in the node header. */ + virtual String getNodeTitle() const = 0; + + /** Returns the number of input ports. */ + virtual int getNumInputPorts() const = 0; + + /** Returns the number of output ports. */ + virtual int getNumOutputPorts() const = 0; + + /** Returns the node accent color. */ + virtual Color getNodeColor() const; + + /** Returns the subtitle rendered below the title. */ + virtual String getNodeSubtitle() const; + + /** Returns display metadata for an input port. */ + virtual PortInfo getInputPortInfo (int busIndex) const; + + /** Returns display metadata for an output port. */ + virtual PortInfo getOutputPortInfo (int busIndex) const; + + /** Returns the number of inline parameter rows. */ + virtual int getNumParameterRows() const; + + /** Returns display metadata for an inline parameter row. */ + virtual ParameterInfo getParameterInfo (int parameterIndex) const; + + /** Paints optional custom content between the header and the port rows. */ + virtual void paintNodeContent (Graphics& g, Rectangle contentBounds) const; + + /** Returns the preferred width in canvas units. */ + virtual int getPreferredWidth() const; + + /** Returns the computed preferred height in canvas units. */ + int getPreferredHeight() const; + + /** Returns the local center of an input port. */ + Point getInputPortCenter (int busIndex) const; + + /** Returns the local center of an output port. */ + Point getOutputPortCenter (int busIndex) const; + + /** Returns the port radius in current local display units. */ + float getPortRadius() const; + + /** Returns the port at localPos when it is within the port radius. */ + std::optional hitTestPort (Point localPos) const; + + /** Returns the default color for a semantic port kind. */ + static Color getPortKindColor (PortKind kind); + + /** @internal Sets the display scale applied by AudioGraphComponent. */ + void setViewScale (float newScale); + + /** @internal Returns the display scale applied by AudioGraphComponent. */ + float getViewScale() const noexcept { return viewScale; } + + /** @internal */ + void paint (Graphics& g) override; + +private: + Point getPortCenter (int busIndex, bool isInput) const; + Rectangle getPortArea() const; + + AudioGraphNodeID nodeID; + float viewScale = 1.0f; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioGraphNodeView) +}; + +} // namespace yup diff --git a/modules/yup_audio_gui/waveform/yup_AudioThumbnail.cpp b/modules/yup_audio_gui/waveform/yup_AudioThumbnail.cpp index b69030b02..b71ef37da 100644 --- a/modules/yup_audio_gui/waveform/yup_AudioThumbnail.cpp +++ b/modules/yup_audio_gui/waveform/yup_AudioThumbnail.cpp @@ -189,7 +189,8 @@ void AudioThumbnail::paintChannel (Graphics& g, // Calculate how many pixels we'll actually draw const int pixelColumns = jmin (static_cast (pixelWidth), static_cast (lane.getWidth())); - if (pixelColumns <= 0) + const int pixelRows = jmin (static_cast (pixelWidth), static_cast (lane.getHeight())); + if (pixelColumns <= 0 || pixelRows <= 0) return; // Calculate zoom level and select best aggregation level diff --git a/modules/yup_audio_gui/yup_audio_gui.cpp b/modules/yup_audio_gui/yup_audio_gui.cpp index a1d1e2254..4a2409062 100644 --- a/modules/yup_audio_gui/yup_audio_gui.cpp +++ b/modules/yup_audio_gui/yup_audio_gui.cpp @@ -40,3 +40,5 @@ #include "displays/yup_SpectrumAnalyzerComponent.cpp" #include "displays/yup_CartesianPlane.cpp" #include "metering/yup_KMeterComponent.cpp" +#include "graph/yup_AudioGraphNodeView.cpp" +#include "graph/yup_AudioGraphComponent.cpp" diff --git a/modules/yup_audio_gui/yup_audio_gui.h b/modules/yup_audio_gui/yup_audio_gui.h index 1574a0ac9..79ecaef4b 100644 --- a/modules/yup_audio_gui/yup_audio_gui.h +++ b/modules/yup_audio_gui/yup_audio_gui.h @@ -32,7 +32,7 @@ website: https://github.com/kunitoki/yup license: ISC - dependencies: yup_audio_basics yup_audio_formats yup_dsp yup_gui + dependencies: yup_audio_basics yup_audio_formats yup_audio_processors yup_audio_graph yup_dsp yup_gui END_YUP_MODULE_DECLARATION @@ -44,6 +44,8 @@ #include #include +#include +#include #include #include @@ -57,3 +59,5 @@ #include "displays/yup_SpectrumAnalyzerComponent.h" #include "displays/yup_CartesianPlane.h" #include "metering/yup_KMeterComponent.h" +#include "graph/yup_AudioGraphNodeView.h" +#include "graph/yup_AudioGraphComponent.h" diff --git a/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp b/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp index bb24117d0..beb01079d 100644 --- a/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp +++ b/modules/yup_audio_plugin_client/clap/yup_audio_plugin_client_CLAP.cpp @@ -603,9 +603,16 @@ bool AudioPluginProcessorCLAP::initialise() auto wrapper = getWrapper (plugin); auto* audioProcessor = wrapper->audioProcessor.get(); - return static_cast (isInput - ? audioProcessor->getBusLayout().getInputBuses().size() - : audioProcessor->getBusLayout().getOutputBuses().size()); + Span busses = isInput + ? audioProcessor->getBusLayout().getInputBuses() + : audioProcessor->getBusLayout().getOutputBuses(); + + uint32_t count = 0; + for (const auto& bus : busses) + if (bus.getType() == AudioBus::Type::Audio) + ++count; + + return count; }; extensionAudioPorts.get = [] (const clap_plugin_t* plugin, uint32_t index, bool isInput, clap_audio_port_info_t* info) -> bool @@ -617,17 +624,32 @@ bool AudioPluginProcessorCLAP::initialise() ? audioProcessor->getBusLayout().getInputBuses() : audioProcessor->getBusLayout().getOutputBuses(); - if (index >= static_cast (busses.size())) - return false; + const AudioBus* audioBus = nullptr; + uint32_t audioBusIndex = 0; - const AudioBus& bus = busses[index]; + for (const auto& bus : busses) + { + if (bus.getType() != AudioBus::Type::Audio) + continue; + + if (audioBusIndex == index) + { + audioBus = &bus; + break; + } + + ++audioBusIndex; + } + + if (audioBus == nullptr) + return false; info->id = index; - info->channel_count = bus.getNumChannels(); + info->channel_count = audioBus->getNumChannels(); info->flags = (index == 0) ? CLAP_AUDIO_PORT_IS_MAIN : 0; - info->port_type = bus.isStereo() ? CLAP_PORT_STEREO : CLAP_PORT_MONO; + info->port_type = audioBus->isStereo() ? CLAP_PORT_STEREO : CLAP_PORT_MONO; info->in_place_pair = CLAP_INVALID_ID; - bus.getName().copyToUTF8 (info->name, sizeof (info->name)); + audioBus->getName().copyToUTF8 (info->name, sizeof (info->name)); return true; }; diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginDescription.h b/modules/yup_audio_plugin_host/host/yup_AudioPluginDescription.h new file mode 100644 index 000000000..ee6401553 --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginDescription.h @@ -0,0 +1,100 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** + Stable metadata describing a discovered audio plugin. + + Instances are produced by AudioPluginScanner and consumed by + AudioPluginFormat::loadPlugin(). Fields are format-independent. +*/ +struct AudioPluginDescription +{ + /** The plugin format. */ + AudioPluginFormatType formatType = AudioPluginFormatType::unknown; + + /** Human-readable name (e.g. "Surge XT"). */ + String name; + + /** Plugin vendor / manufacturer name. */ + String vendor; + + /** Version string as reported by the plugin. */ + String version; + + /** Plugin category string (e.g. "Instrument", "Fx"). */ + String category; + + /** + Format-specific unique identifier. + - VST3: base64-encoded 16-byte FUID + - CLAP: clap_plugin_descriptor::id string + - AUv2: "type/subt/mfgr" four-char-code triplet + */ + String identifier; + + /** Absolute path to the plugin file or bundle on disk. */ + String fileOrBundlePath; + + /** True when the plugin is a synthesiser / instrument. */ + bool isInstrument = false; + + /** True when the plugin is an audio effect. */ + bool isEffect = false; + + /** Reported input channel count (sum across main audio input buses). */ + int numInputChannels = 0; + + /** Reported output channel count (sum across main audio output buses). */ + int numOutputChannels = 0; + + /** Reported MIDI input port count. */ + int numMidiInputPorts = 0; + + /** Reported MIDI output port count. */ + int numMidiOutputPorts = 0; + + bool operator== (const AudioPluginDescription& other) const noexcept + { + return formatType == other.formatType + && name == other.name + && vendor == other.vendor + && version == other.version + && category == other.category + && identifier == other.identifier + && fileOrBundlePath == other.fileOrBundlePath + && isInstrument == other.isInstrument + && isEffect == other.isEffect + && numInputChannels == other.numInputChannels + && numOutputChannels == other.numOutputChannels + && numMidiInputPorts == other.numMidiInputPorts + && numMidiOutputPorts == other.numMidiOutputPorts; + } + + bool operator!= (const AudioPluginDescription& other) const noexcept + { + return ! (*this == other); + } +}; + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginFormat.h b/modules/yup_audio_plugin_host/host/yup_AudioPluginFormat.h new file mode 100644 index 000000000..1ebe51625 --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginFormat.h @@ -0,0 +1,110 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +class AudioPluginInstance; + +/** + Abstract interface implemented by each native plugin format backend. + + One concrete subclass exists per supported format (VST3, CLAP, AUv2). + Clients register format objects with AudioPluginScanner. + + scanFile() may be called from scanner worker threads. Other methods must + be safe for the host call site that invokes them. +*/ +class AudioPluginFormat +{ +public: + virtual ~AudioPluginFormat() = default; + + //============================================================================== + + /** Returns the format type this backend handles. */ + virtual AudioPluginFormatType getFormatType() const = 0; + + /** Returns a human-readable name for the format (e.g. "VST3"). */ + virtual String getFormatName() const = 0; + + /** + Returns the file extensions this format recognises, including the leading dot + (e.g. { ".vst3" } or { ".clap" }). + + The scanner uses these extensions to filter filesystem entries before calling + scanFile(). Return an empty array for registry-based formats (e.g. AUv2) that + do not perform file-system scanning — the scanner will call scanFile() with an + invalid File instead. + + Platform-specific extensions (e.g. ".dll" on Windows, ".so" on Linux) should + be returned conditionally so only the relevant extensions are active on each + platform. + */ + virtual StringArray getFileExtensions() const = 0; + + //============================================================================== + + /** + Returns the platform-standard search paths for this format. + + - VST3 macOS: /Library/Audio/Plug-Ins/VST3, ~/Library/Audio/Plug-Ins/VST3 + - VST3 Windows: %CommonProgramFiles%\VST3, %APPDATA%\VST3 + - VST3 Linux: /usr/lib/vst3, ~/.vst3 + - CLAP macOS: /Library/Audio/Plug-Ins/CLAP, ~/Library/Audio/Plug-Ins/CLAP + - CLAP Windows: %CommonProgramFiles%\CLAP, %APPDATA%\CLAP + - CLAP Linux: /usr/lib/clap, ~/.clap + - AUv2: Returns empty — AUv2 discovery uses AudioComponent registry. + */ + virtual FileSearchPath getDefaultSearchPaths() const = 0; + + //============================================================================== + + /** + Scans one file or bundle and returns all plugin descriptions it contains. + + Returns a failure result if the file is not a supported plugin for this + format. The scanner calls this only for files whose extension matches + expectations (e.g. ".vst3", ".clap"). + + @param file Absolute path to a file or bundle. + @return All descriptions found, or a failure message. + */ + virtual ResultValue> scanFile (const File& file) = 0; + + //============================================================================== + + /** + Loads and activates a plugin described by @p description. + + The returned instance is ready for prepareToPlay() but is not yet + prepared. Returns a failure result on any error. + + @param description A description previously produced by scanFile(). + @param context Host context supplying sample rate, block size, etc. + @return Owning pointer to the live instance, or a failure. + */ + virtual ResultValue> loadPlugin ( + const AudioPluginDescription& description, + const AudioPluginHostContext& context) = 0; +}; + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginFormatType.h b/modules/yup_audio_plugin_host/host/yup_AudioPluginFormatType.h new file mode 100644 index 000000000..f0da8990f --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginFormatType.h @@ -0,0 +1,34 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** Identifies the native format of a plugin. */ +enum class AudioPluginFormatType +{ + vst3, + clap, + audioUnit, + unknown +}; + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginHostContext.h b/modules/yup_audio_plugin_host/host/yup_AudioPluginHostContext.h new file mode 100644 index 000000000..9edcd76b7 --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginHostContext.h @@ -0,0 +1,59 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** + Runtime context provided to a plugin when it is loaded. + + Pass one AudioPluginHostContext to AudioPluginFormat::loadPlugin(). + The same context can be shared across multiple instances; only + sampleRate and maxBlockSize have per-instance meaning. +*/ +struct AudioPluginHostContext +{ + /** Sample rate the plugin will be prepared at. */ + float sampleRate = 44100.0f; + + /** Maximum number of samples per processBlock() call. */ + int maxBlockSize = 512; + + /** True to prefer double-precision processing where the plugin supports it. */ + bool preferDoublePrecision = false; + + /** True when the host is preparing the plugin for offline/non-realtime rendering. */ + bool isNonRealtime = false; + + /** Optional playhead — the host sets this before processing. */ + AudioPlayHead* playHead = nullptr; + + /** Host application name reported to the plugin. */ + String hostName = "YUP Audio Plugin Host"; + + /** Host vendor reported to the plugin. */ + String hostVendor = "YUP"; + + /** Host version string reported to the plugin. */ + String hostVersion = "1.0.0"; +}; + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginInstance.cpp b/modules/yup_audio_plugin_host/host/yup_AudioPluginInstance.cpp new file mode 100644 index 000000000..fb4cb596c --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginInstance.cpp @@ -0,0 +1,112 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +namespace +{ + +template +void processPluginBypassedBlock (const AudioPluginDescription& description, + AudioBuffer& audioBuffer) noexcept +{ + const int numSamples = audioBuffer.getNumSamples(); + const int numBufferChannels = audioBuffer.getNumChannels(); + const int numInputs = jmin (description.numInputChannels, numBufferChannels); + const int numOutputs = jmin (description.numOutputChannels, numBufferChannels); + const int numChannelsToCopy = jmin (numInputs, numOutputs); + + for (int channel = 0; channel < numChannelsToCopy; ++channel) + { + const auto* source = audioBuffer.getReadPointer (channel); + auto* destination = audioBuffer.getWritePointer (channel); + + if (source != destination) + FloatVectorOperations::copy (destination, source, numSamples); + } + + for (int channel = numChannelsToCopy; channel < numOutputs; ++channel) + audioBuffer.clear (channel, 0, numSamples); +} + +} // namespace + +AudioPluginInstance::AudioPluginInstance (const AudioPluginDescription& description, + AudioBusLayout busLayout) + : AudioProcessor (description.name, std::move (busLayout)) + , pluginDescription (description) +{ +} + +AudioPluginInstance::~AudioPluginInstance() = default; + +const AudioPluginDescription& AudioPluginInstance::getDescription() const noexcept +{ + return pluginDescription; +} + +AudioPluginFormatType AudioPluginInstance::getFormatType() const noexcept +{ + return pluginDescription.formatType; +} + +void AudioPluginInstance::setNonRealtime (bool shouldBeNonRealtime) noexcept +{ + if (nonRealtime == shouldBeNonRealtime) + return; + + nonRealtime = shouldBeNonRealtime; + nonRealtimeStateChanged(); +} + +bool AudioPluginInstance::isNonRealtime() const noexcept +{ + return nonRealtime; +} + +void AudioPluginInstance::setBypassed (bool shouldBeBypassed) noexcept +{ + if (bypassed == shouldBeBypassed) + return; + + bypassed = shouldBeBypassed; + bypassStateChanged(); +} + +bool AudioPluginInstance::isBypassed() const noexcept +{ + return bypassed; +} + +void AudioPluginInstance::processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) +{ + ignoreUnused (midiBuffer); + processPluginBypassedBlock (pluginDescription, audioBuffer); +} + +void AudioPluginInstance::processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) +{ + ignoreUnused (midiBuffer); + processPluginBypassedBlock (pluginDescription, audioBuffer); +} + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginInstance.h b/modules/yup_audio_plugin_host/host/yup_AudioPluginInstance.h new file mode 100644 index 000000000..e696344b6 --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginInstance.h @@ -0,0 +1,95 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** + An AudioProcessor that wraps a loaded third-party plugin. + + Concrete subclasses are created by AudioPluginFormat::loadPlugin() — callers + never instantiate this directly. Interact with the plugin through the + AudioProcessor interface; format-specific behaviour stays in the subclass. + + Ownership: returned as std::unique_ptr from loadPlugin(). +*/ +class AudioPluginInstance : public AudioProcessor +{ +public: + //============================================================================== + + /** @internal Used by format backends. */ + AudioPluginInstance (const AudioPluginDescription& description, + AudioBusLayout busLayout); + + ~AudioPluginInstance() override; + + //============================================================================== + + /** Returns the description that was used to load this instance. */ + const AudioPluginDescription& getDescription() const noexcept; + + //============================================================================== + + /** Returns the format type of this instance. */ + AudioPluginFormatType getFormatType() const noexcept; + + //============================================================================== + + /** Sets whether this instance should render in offline/non-realtime mode. */ + void setNonRealtime (bool shouldBeNonRealtime) noexcept; + + /** Returns true when this instance is rendering in offline/non-realtime mode. */ + bool isNonRealtime() const noexcept; + + /** Sets whether processBlock() should render the hosted plugin bypassed. */ + void setBypassed (bool shouldBeBypassed) noexcept; + + /** Returns true when this instance is currently bypassed. */ + bool isBypassed() const noexcept; + + /** Processes a bypassed single-precision block by copying matching inputs to + outputs and clearing outputs without matching inputs. + */ + void processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override; + + /** Processes a bypassed double-precision block by copying matching inputs to + outputs and clearing outputs without matching inputs. + */ + void processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override; + +protected: + AudioPluginDescription pluginDescription; + + /** Called after setNonRealtime() changes state. */ + virtual void nonRealtimeStateChanged() {} + + /** Called after setBypassed() changes state. */ + virtual void bypassStateChanged() {} + +private: + bool nonRealtime = false; + bool bypassed = false; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioPluginInstance) +}; + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginScanner.cpp b/modules/yup_audio_plugin_host/host/yup_AudioPluginScanner.cpp new file mode 100644 index 000000000..5db4ff8a0 --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginScanner.cpp @@ -0,0 +1,244 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +namespace +{ + +struct FileScanTask +{ + File file; + AudioPluginFormat* format = nullptr; + std::vector discovered; + String failedPath; +}; + +void runFileScanTask (FileScanTask& task) +{ + jassert (task.format != nullptr); + + auto scanResult = task.format->scanFile (task.file); + + if (scanResult.wasOk()) + { + task.discovered = std::move (scanResult).getValue(); + return; + } + + task.failedPath = task.file.getFullPathName(); +} + +} // namespace + +AudioPluginScanner::AudioPluginScanner() + : backgroundScanPool (ThreadPool::Options {} + .withThreadName ("YUP Plugin Scanner") + .withNumberOfThreads (2)) +{ +} + +AudioPluginScanner::~AudioPluginScanner() +{ + cancelPendingScans(); +} + +void AudioPluginScanner::addFormat (std::unique_ptr format) +{ + jassert (format != nullptr); + + const auto type = format->getFormatType(); + + for (auto& existing : formats) + { + if (existing->getFormatType() == type) + { + existing = std::move (format); + return; + } + } + + formats.push_back (std::move (format)); +} + +int AudioPluginScanner::getNumFormats() const noexcept +{ + return static_cast (formats.size()); +} + +AudioPluginFormat* AudioPluginScanner::getFormat (int index) const noexcept +{ + if (index < 0 || index >= static_cast (formats.size())) + return nullptr; + + return formats[static_cast (index)].get(); +} + +AudioPluginFormat* AudioPluginScanner::getFormatForType (AudioPluginFormatType type) const noexcept +{ + for (const auto& f : formats) + { + if (f->getFormatType() == type) + return f.get(); + } + + return nullptr; +} + +AudioPluginScanner::ScanResult AudioPluginScanner::scan (const FileSearchPath& searchPath) +{ + ScanResult result; + + for (const auto& format : formats) + { + if (format->getFileExtensions().isEmpty()) + { + auto scanResult = format->scanFile (File {}); + if (scanResult.wasOk()) + { + auto descriptions = scanResult.getValue(); + result.discovered.insert (result.discovered.end(), + descriptions.begin(), + descriptions.end()); + } + } + } + + std::vector tasks; + const int numPaths = searchPath.getNumPaths(); + + for (const auto& format : formats) + { + String wildcardPattern; + for (const auto& ext : format->getFileExtensions()) + { + if (wildcardPattern.isNotEmpty()) + wildcardPattern += ";"; + + wildcardPattern += "*" + ext; + } + + if (wildcardPattern.isEmpty()) + continue; + + for (int p = 0; p < numPaths; ++p) + { + Array files; + searchPath[p].findChildFiles (files, File::findFilesAndDirectories, true, wildcardPattern, File::FollowSymlinks::noCycles); + + for (const auto& file : files) + { + FileScanTask task; + task.file = file; + task.format = format.get(); + tasks.push_back (std::move (task)); + } + } + } + + if (tasks.empty()) + return result; + + if (tasks.size() == 1) + { + runFileScanTask (tasks.front()); + } + else + { + WaitableEvent finished; + std::atomic remainingTasks { static_cast (tasks.size()) }; + + ThreadPool pool (ThreadPool::Options {} + .withThreadName ("YUP Plugin File Scanner") + .withNumberOfThreads (jmax (1, jmin (SystemStats::getNumCpus(), static_cast (tasks.size()))))); + + for (std::size_t i = 0; i < tasks.size(); ++i) + { + pool.addJob ([&tasks, &remainingTasks, &finished, i] + { + runFileScanTask (tasks[i]); + + if (--remainingTasks == 0) + finished.signal(); + }); + } + + finished.wait(); + } + + for (auto& task : tasks) + { + result.discovered.insert (result.discovered.end(), + task.discovered.begin(), + task.discovered.end()); + + if (task.failedPath.isNotEmpty()) + result.failedPaths.push_back (task.failedPath); + } + + return result; +} + +AudioPluginScanner::ScanResult AudioPluginScanner::scanDefaults() +{ + FileSearchPath combined; + + for (const auto& format : formats) + { + const auto paths = format->getDefaultSearchPaths(); + const int n = paths.getNumPaths(); + + for (int i = 0; i < n; ++i) + combined.addIfNotAlreadyThere (paths[i]); + } + + return scan (combined); +} + +void AudioPluginScanner::scanAsync (const FileSearchPath& searchPath, ScanCallback callback) +{ + backgroundScanPool.addJob ([this, searchPath, callback = std::move (callback)]() mutable + { + auto result = scan (searchPath); + + if (callback) + callback (std::move (result)); + }); +} + +void AudioPluginScanner::scanDefaultsAsync (ScanCallback callback) +{ + backgroundScanPool.addJob ([this, callback = std::move (callback)]() mutable + { + auto result = scanDefaults(); + + if (callback) + callback (std::move (result)); + }); +} + +void AudioPluginScanner::cancelPendingScans() +{ + backgroundScanPool.removeAllJobs (true, -1); +} + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/host/yup_AudioPluginScanner.h b/modules/yup_audio_plugin_host/host/yup_AudioPluginScanner.h new file mode 100644 index 000000000..e8116e4e1 --- /dev/null +++ b/modules/yup_audio_plugin_host/host/yup_AudioPluginScanner.h @@ -0,0 +1,124 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** + Scans directories for plugins across all registered formats. + + Construct one AudioPluginScanner, register format backends via addFormat(), + then call scan(), scanDefaults(), scanAsync(), or scanDefaultsAsync(). + Results are not cached. + + Thread safety: formats may be scanned concurrently by the scanner. Do not + modify the registered formats while a scan is running. +*/ +class AudioPluginScanner +{ +public: + //============================================================================== + + /** Aggregated output of a scan operation. */ + struct ScanResult + { + /** All successfully parsed plugin descriptions, in discovery order. */ + std::vector discovered; + + /** Absolute paths of files that could not be parsed. */ + std::vector failedPaths; + }; + + //============================================================================== + + /** Callback invoked when an asynchronous scan finishes. */ + using ScanCallback = std::function; + + //============================================================================== + + AudioPluginScanner(); + ~AudioPluginScanner(); + + //============================================================================== + + /** + Registers a format backend. + + If a format with the same AudioPluginFormatType is already registered it + is replaced. Takes ownership. + */ + void addFormat (std::unique_ptr format); + + /** Returns the number of registered formats. */ + int getNumFormats() const noexcept; + + /** Returns the format at @p index, or nullptr if out of range. */ + AudioPluginFormat* getFormat (int index) const noexcept; + + /** Returns the format for the given type, or nullptr if not registered. */ + AudioPluginFormat* getFormatForType (AudioPluginFormatType type) const noexcept; + + //============================================================================== + + /** + Scans all files found under @p searchPath using all registered formats. + + Files and bundles are offered to registered formats whose extension + matches the format. If a format returns a success, its results are + appended; if it fails, the path is added to ScanResult::failedPaths. + + Formats that return an empty array from getFileExtensions() (e.g. AUv2) use + registry-based discovery and are scanned independently of the search path. + */ + ScanResult scan (const FileSearchPath& searchPath); + + /** + Scans each registered format's default search paths. + */ + ScanResult scanDefaults(); + + /** + Starts scanning @p searchPath on a background thread. + + The callback is invoked from a scanner worker thread. UI clients should + dispatch back to the message thread before touching components. + */ + void scanAsync (const FileSearchPath& searchPath, ScanCallback callback); + + /** + Starts scanning each registered format's default search paths on a + background thread. + */ + void scanDefaultsAsync (ScanCallback callback); + + /** + Attempts to stop queued or running asynchronous scans. + */ + void cancelPendingScans(); + +private: + std::vector> formats; + ThreadPool backgroundScanPool; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioPluginScanner) +}; + +} // namespace yup diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.h b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.h new file mode 100644 index 000000000..08a597704 --- /dev/null +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.h @@ -0,0 +1,59 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_AU && YUP_MAC + +namespace yup +{ + +/** + AudioPluginFormat implementation for AUv2 (macOS only). + + Enumerates AudioComponents via AudioComponentFindNext() and wraps each + component via AudioComponentInstanceNew(). +*/ +class AUv2Format : public AudioPluginFormat +{ +public: + AUv2Format(); + ~AUv2Format() override; + + AudioPluginFormatType getFormatType() const override; + String getFormatName() const override; + StringArray getFileExtensions() const override; + + /** Returns an empty FileSearchPath — AUv2 uses AudioComponent registry. */ + FileSearchPath getDefaultSearchPaths() const override; + + /** + Passing an invalid File triggers full AudioComponent registry scan. + Otherwise scans only the component matching the file's bundle identifier. + */ + ResultValue> scanFile (const File& file) override; + + ResultValue> loadPlugin ( + const AudioPluginDescription& description, + const AudioPluginHostContext& context) override; +}; + +} // namespace yup + +#endif // YUP_AUDIO_PLUGIN_HOST_ENABLE_AU && YUP_MAC diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm new file mode 100644 index 000000000..d20d10dff --- /dev/null +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm @@ -0,0 +1,1250 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_AU && YUP_MAC + +namespace yup +{ + +namespace +{ + +//============================================================================== +// Converts a four-char code to a 4-char String (e.g. 0x61756666 -> "auff"). +String fourCCToString(OSType code) +{ + char buf[5] = {}; + buf[0] = static_cast((code >> 24) & 0xFF); + buf[1] = static_cast((code >> 16) & 0xFF); + buf[2] = static_cast((code >> 8) & 0xFF); + buf[3] = static_cast(code & 0xFF); + return String(buf); +} + +String copyCFString(CFStringRef string) +{ + return String::fromCFString(string).trim(); +} + +// Builds a stable identifier string "type/subt/mfgr" from AudioComponentDescription. +String makeIdentifier(const AudioComponentDescription& acd) +{ + return fourCCToString(acd.componentType) + "/" + fourCCToString(acd.componentSubType) + "/" + fourCCToString(acd.componentManufacturer); +} + +AudioPluginDescription descriptionFromComponent(AudioComponent comp, + const AudioComponentDescription& acd) +{ + AudioPluginDescription desc; + desc.formatType = AudioPluginFormatType::audioUnit; + desc.identifier = makeIdentifier(acd); + + CFStringRef nameRef = nullptr; + AudioComponentCopyName(comp, &nameRef); + if (nameRef != nullptr) + { + desc.name = copyCFString(nameRef); + CFRelease(nameRef); + } + + // Collect vendor from the manufacturer four-char code + desc.vendor = fourCCToString(acd.componentManufacturer); + + desc.isInstrument = (acd.componentType == kAudioUnitType_MusicDevice); + desc.isEffect = (acd.componentType == kAudioUnitType_Effect || acd.componentType == kAudioUnitType_MusicEffect); + + if (acd.componentType == kAudioUnitType_MusicDevice || acd.componentType == kAudioUnitType_MusicEffect || acd.componentType == kAudioUnitType_MIDIProcessor) + { + desc.numMidiInputPorts = 1; + } + + if (acd.componentType == kAudioUnitType_MIDIProcessor) + desc.numMidiOutputPorts = 1; + + if (acd.componentType == kAudioUnitType_Effect || acd.componentType == kAudioUnitType_MusicEffect) + { + desc.numInputChannels = 2; + desc.numOutputChannels = 2; + } + else if (acd.componentType == kAudioUnitType_MusicDevice || acd.componentType == kAudioUnitType_Generator) + { + desc.numOutputChannels = 2; + } + + return desc; +} + +String makeParameterName(const AudioUnitParameterInfo& info, + AudioUnitParameterID paramId) +{ + String name; + + if ((info.flags & kAudioUnitParameterFlag_HasCFNameString) != 0) + name = copyCFString(info.cfNameString); + + if (name.isEmpty()) + name = String(info.name).trim(); + + return name.isNotEmpty() ? name : "Parameter " + String(paramId); +} + +void releaseParameterInfoStrings(AudioUnitParameterInfo& info) +{ + if ((info.flags & kAudioUnitParameterFlag_CFNameRelease) == 0) + return; + + if (info.cfNameString != nullptr) + { + CFRelease(info.cfNameString); + info.cfNameString = nullptr; + } + + if (info.unitName != nullptr) + { + CFRelease(info.unitName); + info.unitName = nullptr; + } +} + +NormalisableRange makeParameterRange(const AudioUnitParameterInfo& info) +{ + const auto minValue = static_cast(info.minValue); + const auto maxValue = static_cast(info.maxValue); + + if (maxValue > minValue) + return {minValue, maxValue}; + + return {0.0f, 1.0f}; +} + +AudioUnitParameter makeAUParameter(AudioUnit audioUnit, AudioUnitParameterID paramId) +{ + AudioUnitParameter parameter; + parameter.mAudioUnit = audioUnit; + parameter.mParameterID = paramId; + parameter.mScope = kAudioUnitScope_Global; + parameter.mElement = 0; + return parameter; +} + +AudioUnitEvent makeAUParameterEvent(AudioUnit audioUnit, + AudioUnitParameterID paramId, + AudioUnitEventType eventType) +{ + AudioUnitEvent event; + event.mEventType = eventType; + event.mArgument.mParameter = makeAUParameter(audioUnit, paramId); + return event; +} + +} // namespace + +//============================================================================== + +class AUv2Editor : public AudioProcessorEditor +{ + public: + static std::unique_ptr create(AudioUnit audioUnit) + { + UInt32 size = 0; + Boolean writable = false; + + if (AudioUnitGetPropertyInfo(audioUnit, + kAudioUnitProperty_CocoaUI, + kAudioUnitScope_Global, 0, + &size, &writable) != noErr) + { + return nullptr; + } + + if (size < offsetof(AudioUnitCocoaViewInfo, mCocoaAUViewClass) + sizeof(CFStringRef)) + return nullptr; + + std::vector storage(size); + auto* viewInfo = reinterpret_cast(storage.data()); + + if (AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_CocoaUI, + kAudioUnitScope_Global, 0, + viewInfo, &size) != noErr) + { + return nullptr; + } + + NSView* view = nil; + NSBundle* viewBundle = nil; + id viewFactory = nil; + + if (viewInfo->mCocoaAUViewBundleLocation != nullptr && viewInfo->mCocoaAUViewClass[0] != nullptr) + { + viewBundle = [NSBundle bundleWithURL:(__bridge NSURL*)viewInfo->mCocoaAUViewBundleLocation]; + + if (viewBundle != nil && [viewBundle load]) + { + Class viewClass = [viewBundle classNamed:(__bridge NSString*)viewInfo->mCocoaAUViewClass[0]]; + + if (viewClass != Nil && [viewClass conformsToProtocol:@protocol(AUCocoaUIBase)]) + { + viewFactory = [[viewClass alloc] init]; + view = [viewFactory uiViewForAudioUnit:audioUnit withSize:NSZeroSize]; + } + } + } + + if (viewInfo->mCocoaAUViewBundleLocation != nullptr) + CFRelease(viewInfo->mCocoaAUViewBundleLocation); + + const auto numViewClasses = static_cast((size - offsetof(AudioUnitCocoaViewInfo, mCocoaAUViewClass)) / sizeof(CFStringRef)); + for (int i = 0; i < numViewClasses; ++i) + if (viewInfo->mCocoaAUViewClass[i] != nullptr) + CFRelease(viewInfo->mCocoaAUViewClass[i]); + + if (view == nil) + return nullptr; + + return std::unique_ptr(new AUv2Editor(view, viewBundle, viewFactory)); + } + + ~AUv2Editor() override + { + detachCocoaView(); + } + + bool isResizable() const override { return true; } + + Size getPreferredSize() const override { return preferredSize; } + + void paint(Graphics& g) override + { + g.setFillColor(Color(0xff101417)); + g.fillAll(); + } + + void resized() override + { + updateCocoaViewFrame(); + } + + void attachedToNative() override + { + attachCocoaView(); + } + + void detachedFromNative() override + { + detachCocoaView(); + } + + private: + AUv2Editor(NSView* view, NSBundle* viewBundle, id viewFactory) + : cocoaView(view), cocoaViewBundle(viewBundle), cocoaViewFactory(viewFactory) + { + auto size = [cocoaView frame].size; + + if (size.width <= 1.0 || size.height <= 1.0) + size = [cocoaView fittingSize]; + + preferredSize = { + jmax(320, static_cast(std::ceil(size.width))), + jmax(240, static_cast(std::ceil(size.height)))}; + + [cocoaView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [cocoaView setFrameSize:NSMakeSize(preferredSize.getWidth(), preferredSize.getHeight())]; + } + + void attachCocoaView() + { + if (cocoaView == nil) + return; + + auto* nativeComponent = getNativeComponent(); + if (nativeComponent == nullptr) + return; + + NSWindow* window = (__bridge NSWindow*)nativeComponent->getNativeHandle(); + NSView* contentView = [window contentView]; + + if (contentView == nil) + return; + + if ([cocoaView superview] != contentView) + [contentView addSubview:cocoaView positioned:NSWindowAbove relativeTo:nil]; + + updateCocoaViewFrame(); + } + + void detachCocoaView() + { + if (cocoaView != nil) + [cocoaView removeFromSuperview]; + } + + void updateCocoaViewFrame() + { + if (cocoaView == nil) + return; + + auto* nativeComponent = getNativeComponent(); + if (nativeComponent == nullptr) + return; + + NSWindow* window = (__bridge NSWindow*)nativeComponent->getNativeHandle(); + NSView* contentView = [window contentView]; + + if (contentView == nil) + return; + + const auto bounds = getBoundsRelativeToTopLevelComponent(); + const auto contentBounds = [contentView bounds]; + const auto width = jmax(1.0f, bounds.getWidth()); + const auto height = jmax(1.0f, bounds.getHeight()); + + [cocoaView setFrame:NSMakeRect(static_cast(bounds.getX()), + static_cast(NSHeight(contentBounds) - bounds.getY() - height), + static_cast(width), + static_cast(height))]; + } + + Size preferredSize{640, 480}; + NSView* __strong cocoaView = nil; + NSBundle* __strong cocoaViewBundle = nil; + id __strong cocoaViewFactory = nil; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AUv2Editor) +}; + +//============================================================================== + +class AUv2Instance : public AudioPluginInstance, private AudioParameter::Listener +{ + public: + AUv2Instance(const AudioPluginDescription& desc, + AudioUnit unit, + AudioBusLayout busLayout) + : AudioPluginInstance(desc, std::move(busLayout)), audioUnit(unit) + { + buildParameterList(); + } + + ~AUv2Instance() override + { + removeParameterListeners(); + releaseResources(); + + if (audioUnit != nullptr) + { + // AudioComponentInstanceDispose must run on the main thread so that + // CoreAudio's internal timers (MAudioPluginInterface::OnTimer etc.) + // registered on CFRunLoopGetMain() are properly invalidated before + // the AudioUnit object is freed. Calling from a background thread + // leaves a window where those timers fire on freed memory. + AudioUnit capturedUnit = audioUnit; + audioUnit = nullptr; + + if ([NSThread isMainThread]) + { + AudioComponentInstanceDispose (capturedUnit); + } + else + { + dispatch_sync (dispatch_get_main_queue(), ^{ + AudioComponentInstanceDispose (capturedUnit); + }); + } + } + } + + //============================================================================== + + void prepareToPlay(float sampleRate, int maxBlockSize) override + { + releaseResources(); + + renderSampleTime = 0.0; + + const auto numHostedChannels = jmax(2, + pluginDescription.numInputChannels, + pluginDescription.numOutputChannels); + prepareRenderStorage(numHostedChannels, maxBlockSize); + + const Float64 sampleRateValue = static_cast(sampleRate); + AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_SampleRate, + kAudioUnitScope_Global, 0, + &sampleRateValue, sizeof(sampleRateValue)); + + UInt32 blockSize = static_cast(maxBlockSize); + AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_MaximumFramesPerSlice, + kAudioUnitScope_Global, 0, + &blockSize, sizeof(blockSize)); + + configureStreamFormat(sampleRateValue, numHostedChannels); + installInputCallback(); + installMIDIOutputCallback(); + + if (![NSThread isMainThread]) + { + AudioUnitInitialize(audioUnit); + return; + } + + // AudioUnitInitialize for AUv3 plugins (exposed via AUv2 API) deadlocks + // on the main thread: allocateRenderResources triggers WorkgroupManager + // init which tries to acquire HALB_Mutex while the HAL I/O thread holds + // it waiting on the main run loop. Dispatching to a background thread + // and pumping the run loop breaks the cycle. + AudioUnit capturedUnit = audioUnit; + __block bool initialized = false; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + AudioUnitInitialize(capturedUnit); + dispatch_async(dispatch_get_main_queue(), ^{ + initialized = true; + }); + }); + + while (!initialized) + [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.005]]; + } + + void releaseResources() override + { + if (audioUnit != nullptr) + AudioUnitUninitialize(audioUnit); + } + + void processBlock(AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override + { + ScopedNoDenormals noDenormals; + + if (isBypassed()) + { + processBlockBypassed(audioBuffer, midiBuffer); + return; + } + + const int numSamples = audioBuffer.getNumSamples(); + const int numChannels = audioBuffer.getNumChannels(); + + if (numSamples <= 0) + return; + + if (numChannels > preparedNumChannels || numSamples > preparedMaxBlockSize) + { + jassertfalse; + return; + } + + outputMidiBuffer.clear(); + currentMidiOutputBuffer = &outputMidiBuffer; + sendMidiInputEvents(midiBuffer); + + if (numChannels <= 0 || pluginDescription.numOutputChannels <= 0) + { + currentMidiOutputBuffer = nullptr; + midiBuffer.clear(); + midiBuffer.addEvents(outputMidiBuffer, 0, -1, 0); + return; + } + + for (int c = 0; c < numChannels; ++c) + { + std::memcpy(inputBuffer.getWritePointer(c), + audioBuffer.getReadPointer(c), + static_cast(numSamples) * sizeof(float)); + } + + currentInputBuffer = &inputBuffer; + currentInputNumChannels = numChannels; + currentInputNumSamples = numSamples; + + AudioUnitRenderActionFlags flags = 0; + AudioTimeStamp timeStamp{}; + timeStamp.mFlags = kAudioTimeStampSampleTimeValid; + timeStamp.mSampleTime = renderSampleTime; + renderSampleTime += numSamples; + + AudioBufferList* abl = makeOutputABL(audioBuffer); + + const auto status = AudioUnitRender(audioUnit, + &flags, + &timeStamp, + 0, // output bus + static_cast(numSamples), + abl); + + currentInputBuffer = nullptr; + currentInputNumChannels = 0; + currentInputNumSamples = 0; + currentMidiOutputBuffer = nullptr; + + if (status != noErr) + { + copyInputBackTo(audioBuffer, numChannels, numSamples); + return; + } + + // Copy rendered output back to the YUP buffer + for (int c = 0; c < numChannels; ++c) + { + std::memcpy(audioBuffer.getWritePointer(c), + abl->mBuffers[c].mData, + static_cast(numSamples) * sizeof(float)); + } + + midiBuffer.clear(); + midiBuffer.addEvents(outputMidiBuffer, 0, -1, 0); + } + + //============================================================================== + + int getCurrentPreset() const noexcept override { return currentPreset; } + + void setCurrentPreset(int index) noexcept override + { + CFArrayRef presets = nullptr; + UInt32 size = sizeof(presets); + + if (AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_FactoryPresets, + kAudioUnitScope_Global, 0, + &presets, &size) != noErr) + { + return; + } + + if (presets == nullptr || !isPositiveAndBelow(index, static_cast(CFArrayGetCount(presets)))) + { + if (presets != nullptr) + CFRelease(presets); + + return; + } + + auto* auPreset = static_cast(CFArrayGetValueAtIndex(presets, index)); + if (auPreset != nullptr) + { + AUPreset preset = *auPreset; + + if (AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_PresentPreset, + kAudioUnitScope_Global, 0, + &preset, sizeof(preset)) == noErr) + { + currentPreset = index; + syncParameterValuesFromAudioUnit(); + notifyAudioUnitParametersChanged(); + } + } + + CFRelease(presets); + } + + int getNumPresets() const override + { + CFArrayRef presets = nullptr; + UInt32 size = sizeof(presets); + + AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_FactoryPresets, + kAudioUnitScope_Global, 0, + &presets, &size); + + if (presets == nullptr) + return 0; + + const int count = static_cast(CFArrayGetCount(presets)); + CFRelease(presets); + return count; + } + + String getPresetName(int index) const override + { + CFArrayRef presets = nullptr; + UInt32 size = sizeof(presets); + + AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_FactoryPresets, + kAudioUnitScope_Global, 0, + &presets, &size); + + if (presets == nullptr || !isPositiveAndBelow(index, static_cast(CFArrayGetCount(presets)))) + { + if (presets != nullptr) + CFRelease(presets); + return {}; + } + + auto* auPreset = static_cast(CFArrayGetValueAtIndex(presets, index)); + const String name = auPreset->presetName != nullptr + ? String::fromCFString(auPreset->presetName) + : String(); + CFRelease(presets); + return name; + } + + void setPresetName(int, StringRef) override {} + + //============================================================================== + + Result loadStateFromMemory(const MemoryBlock& memoryBlock) override + { + CFDataRef data = CFDataCreateWithBytesNoCopy(kCFAllocatorDefault, + static_cast(memoryBlock.getData()), + static_cast(memoryBlock.getSize()), + kCFAllocatorNull); + if (data == nullptr) + return Result::fail("Failed to create CFData"); + + CFPropertyListRef plist = CFPropertyListCreateWithData(kCFAllocatorDefault, + data, + kCFPropertyListImmutable, + nullptr, nullptr); + CFRelease(data); + + if (plist == nullptr) + return Result::fail("Failed to deserialize AU state"); + + const OSStatus status = AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_ClassInfo, + kAudioUnitScope_Global, 0, + &plist, sizeof(plist)); + CFRelease(plist); + + if (status != noErr) + return Result::fail("AudioUnitSetProperty ClassInfo failed: " + String(status)); + + syncParameterValuesFromAudioUnit(); + notifyAudioUnitParametersChanged(); + return Result::ok(); + } + + Result saveStateIntoMemory(MemoryBlock& memoryBlock) override + { + CFPropertyListRef plist = nullptr; + UInt32 size = sizeof(plist); + + const OSStatus status = AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_ClassInfo, + kAudioUnitScope_Global, 0, + &plist, &size); + if (status != noErr || plist == nullptr) + return Result::fail("AudioUnitGetProperty ClassInfo failed: " + String(status)); + + CFDataRef data = CFPropertyListCreateData(kCFAllocatorDefault, + plist, + kCFPropertyListBinaryFormat_v1_0, + 0, nullptr); + CFRelease(plist); + + if (data == nullptr) + return Result::fail("Failed to serialize AU state"); + + memoryBlock.replaceAll(CFDataGetBytePtr(data), + static_cast(CFDataGetLength(data))); + CFRelease(data); + return Result::ok(); + } + + //============================================================================== + + bool hasEditor() const override + { + UInt32 size = 0; + Boolean writable = false; + + return AudioUnitGetPropertyInfo(audioUnit, + kAudioUnitProperty_CocoaUI, + kAudioUnitScope_Global, 0, + &size, &writable) == noErr && + size >= offsetof(AudioUnitCocoaViewInfo, mCocoaAUViewClass) + sizeof(CFStringRef); + } + + AudioProcessorEditor* createEditor() override + { + if (auto editor = AUv2Editor::create(audioUnit)) + return editor.release(); + + return nullptr; + } + + //============================================================================== + + static std::unique_ptr create(const AudioPluginDescription& desc, + const AudioPluginHostContext& context) + { + // Parse "type/subt/mfgr" identifier + const auto tokens = StringArray::fromTokens(desc.identifier, "/", ""); + if (tokens.size() != 3) + return nullptr; + + auto fourCC = [](const String& s) -> OSType + { + if (s.length() != 4) + return 0; + return static_cast( + (static_cast(s[0]) << 24) | (static_cast(s[1]) << 16) | (static_cast(s[2]) << 8) | static_cast(s[3])); + }; + + AudioComponentDescription acd{}; + acd.componentType = fourCC(tokens[0]); + acd.componentSubType = fourCC(tokens[1]); + acd.componentManufacturer = fourCC(tokens[2]); + + AudioComponent comp = AudioComponentFindNext(nullptr, &acd); + if (comp == nullptr) + return nullptr; + + AudioUnit unit = nullptr; + if (AudioComponentInstanceNew(comp, &unit) != noErr || unit == nullptr) + return nullptr; + + AudioBusLayout busLayout = makeBusLayout(desc, acd.componentType); + + auto instance = std::make_unique(desc, unit, std::move(busLayout)); + instance->setNonRealtime(context.isNonRealtime); + return instance; + } + + private: + static AudioBusLayout makeBusLayout(const AudioPluginDescription& desc, OSType componentType) + { + std::vector inputs; + std::vector outputs; + + int inputChannels = desc.numInputChannels; + int outputChannels = desc.numOutputChannels; + + if (componentType == kAudioUnitType_Effect || componentType == kAudioUnitType_MusicEffect) + { + outputChannels = jmax(1, outputChannels, 2); + inputChannels = jmax(1, inputChannels, outputChannels); + } + else if (componentType == kAudioUnitType_MusicDevice || componentType == kAudioUnitType_Generator) + { + inputChannels = jmax(0, inputChannels); + outputChannels = jmax(1, outputChannels, 2); + } + + if (inputChannels > 0) + inputs.emplace_back("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, inputChannels); + + if (outputChannels > 0) + outputs.emplace_back("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, outputChannels); + + if (desc.numMidiInputPorts > 0) + inputs.emplace_back("MIDI Input", AudioBus::Type::MIDI, AudioBus::Direction::Input, 1); + + if (desc.numMidiOutputPorts > 0) + outputs.emplace_back("MIDI Output", AudioBus::Type::MIDI, AudioBus::Direction::Output, 1); + + return AudioBusLayout(std::move(inputs), std::move(outputs)); + } + + void parameterValueChanged(const AudioParameter::Ptr& parameter, int indexInContainer) override + { + if (audioUnit == nullptr || !isPositiveAndBelow(indexInContainer, static_cast(auParameterIds.size()))) + return; + + const auto auParameter = makeAUParameter(audioUnit, auParameterIds[static_cast(indexInContainer)]); + + AUParameterSet(eventListener, + this, + &auParameter, + static_cast(parameter->getValue()), + 0); + } + + void parameterGestureBegin(const AudioParameter::Ptr&, int indexInContainer) override + { + notifyParameterGesture(indexInContainer, kAudioUnitEvent_BeginParameterChangeGesture); + } + + void parameterGestureEnd(const AudioParameter::Ptr&, int indexInContainer) override + { + notifyParameterGesture(indexInContainer, kAudioUnitEvent_EndParameterChangeGesture); + } + + void notifyParameterGesture(int indexInContainer, AudioUnitEventType eventType) + { + if (audioUnit == nullptr || !isPositiveAndBelow(indexInContainer, static_cast(auParameterIds.size()))) + return; + + const auto event = makeAUParameterEvent(audioUnit, + auParameterIds[static_cast(indexInContainer)], + eventType); + + AUEventListenerNotify(eventListener, this, &event); + } + + static void parameterEventCallback(void* userData, + void*, + const AudioUnitEvent* event, + UInt64, + AudioUnitParameterValue parameterValue) + { + auto* instance = static_cast(userData); + if (instance == nullptr || event == nullptr || event->mEventType != kAudioUnitEvent_ParameterValueChange) + return; + + instance->handleAudioUnitParameterChanged(event->mArgument.mParameter, parameterValue); + } + + void handleAudioUnitParameterChanged(const AudioUnitParameter& parameter, + AudioUnitParameterValue parameterValue) + { + if (parameter.mAudioUnit != audioUnit || parameter.mScope != kAudioUnitScope_Global || parameter.mElement != 0) + { + return; + } + + const auto params = getParameters(); + const auto numParams = jmin(params.size(), auParameterIds.size()); + + for (std::size_t i = 0; i < numParams; ++i) + { + if (auParameterIds[i] == parameter.mParameterID) + { + params[i]->setValue(static_cast(parameterValue)); + return; + } + } + } + + static OSStatus inputRenderCallback(void* refCon, + AudioUnitRenderActionFlags*, + const AudioTimeStamp*, + UInt32, + UInt32 numFrames, + AudioBufferList* ioData) + { + auto* instance = static_cast(refCon); + if (ioData == nullptr) + return noErr; + + if (instance == nullptr || instance->currentInputBuffer == nullptr) + { + for (UInt32 bufferIndex = 0; bufferIndex < ioData->mNumberBuffers; ++bufferIndex) + { + auto& buffer = ioData->mBuffers[bufferIndex]; + auto* dest = static_cast(buffer.mData); + + if (dest != nullptr) + FloatVectorOperations::clear(dest, static_cast(numFrames)); + + buffer.mDataByteSize = static_cast(numFrames * sizeof(float)); + } + + return noErr; + } + + const int framesToCopy = jmin(static_cast(numFrames), + instance->currentInputNumSamples); + + for (UInt32 bufferIndex = 0; bufferIndex < ioData->mNumberBuffers; ++bufferIndex) + { + auto& buffer = ioData->mBuffers[bufferIndex]; + auto* dest = static_cast(buffer.mData); + + if (dest == nullptr) + continue; + + if (static_cast(bufferIndex) < instance->currentInputNumChannels) + { + FloatVectorOperations::copy(dest, + instance->currentInputBuffer->getReadPointer(static_cast(bufferIndex)), + framesToCopy); + } + else + { + FloatVectorOperations::clear(dest, framesToCopy); + } + + if (framesToCopy < static_cast(numFrames)) + FloatVectorOperations::clear(dest + framesToCopy, + static_cast(numFrames) - framesToCopy); + + buffer.mDataByteSize = static_cast(numFrames * sizeof(float)); + } + + return noErr; + } + + void prepareRenderStorage(int numChannels, int maxBlockSize) + { + preparedNumChannels = jmax(1, numChannels); + preparedMaxBlockSize = jmax(1, maxBlockSize); + + inputBuffer.setSize(preparedNumChannels, + preparedMaxBlockSize, + false, + false, + true); + + outputABLStorage.resize(getAudioBufferListSize(preparedNumChannels)); + } + + void configureStreamFormat(Float64 sampleRate, int numChannels) + { + AudioStreamBasicDescription streamFormat{}; + streamFormat.mSampleRate = sampleRate; + streamFormat.mFormatID = kAudioFormatLinearPCM; + streamFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked | kAudioFormatFlagIsNonInterleaved | kAudioFormatFlagsNativeEndian; + streamFormat.mBytesPerPacket = sizeof(float); + streamFormat.mFramesPerPacket = 1; + streamFormat.mBytesPerFrame = sizeof(float); + streamFormat.mChannelsPerFrame = static_cast(numChannels); + streamFormat.mBitsPerChannel = static_cast(sizeof(float) * 8); + + AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Input, 0, + &streamFormat, sizeof(streamFormat)); + + AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, 0, + &streamFormat, sizeof(streamFormat)); + } + + void installInputCallback() + { + AURenderCallbackStruct callback{}; + callback.inputProc = inputRenderCallback; + callback.inputProcRefCon = this; + + AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_SetRenderCallback, + kAudioUnitScope_Input, 0, + &callback, sizeof(callback)); + } + + void installMIDIOutputCallback() + { + AUMIDIOutputCallbackStruct callback{}; + callback.midiOutputCallback = midiOutputCallback; + callback.userData = this; + + AudioUnitSetProperty(audioUnit, + kAudioUnitProperty_MIDIOutputCallback, + kAudioUnitScope_Global, 0, + &callback, sizeof(callback)); + } + + void sendMidiInputEvents(const MidiBuffer& midiBuffer) + { + for (const auto& metadata : midiBuffer) + { + const auto message = metadata.getMessage(); + + if (message.isSysEx()) + { + MusicDeviceSysEx(audioUnit, + message.getSysExData(), + static_cast(message.getSysExDataSize())); + continue; + } + + const auto* data = message.getRawData(); + const int size = message.getRawDataSize(); + + if (size <= 0) + continue; + + MusicDeviceMIDIEvent(audioUnit, + data[0], + size > 1 ? data[1] : 0, + size > 2 ? data[2] : 0, + static_cast(jmax(0, metadata.samplePosition))); + } + } + + static OSStatus midiOutputCallback(void* userData, + const AudioTimeStamp*, + UInt32, + const MIDIPacketList* packetList) + { + auto* instance = static_cast(userData); + if (instance == nullptr || instance->currentMidiOutputBuffer == nullptr || packetList == nullptr) + return noErr; + + const MIDIPacket* packet = &packetList->packet[0]; + + for (UInt32 i = 0; i < packetList->numPackets; ++i) + { + instance->currentMidiOutputBuffer->addEvent(packet->data, + packet->length, + static_cast(packet->timeStamp)); + packet = MIDIPacketNext(packet); + } + + return noErr; + } + + static std::size_t getAudioBufferListSize(int numChannels) + { + return sizeof(AudioBufferList) + static_cast(jmax(0, numChannels - 1)) * sizeof(::AudioBuffer); + } + + AudioBufferList* makeOutputABL(AudioBuffer& buffer) + { + const int numChannels = buffer.getNumChannels(); + auto* abl = reinterpret_cast(outputABLStorage.data()); + abl->mNumberBuffers = static_cast(numChannels); + + for (int c = 0; c < numChannels; ++c) + { + abl->mBuffers[c].mNumberChannels = 1; + abl->mBuffers[c].mDataByteSize = static_cast(buffer.getNumSamples()) * sizeof(float); + abl->mBuffers[c].mData = buffer.getWritePointer(c); + } + + return abl; + } + + void copyInputBackTo(AudioBuffer& audioBuffer, int numChannels, int numSamples) + { + for (int c = 0; c < numChannels; ++c) + { + FloatVectorOperations::copy(audioBuffer.getWritePointer(c), + inputBuffer.getReadPointer(c), + numSamples); + } + } + + void buildParameterList() + { + UInt32 size = 0; + if (AudioUnitGetPropertyInfo(audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, 0, + &size, nullptr) != noErr) + { + return; + } + + const int count = static_cast(size / sizeof(AudioUnitParameterID)); + if (count == 0) + return; + + std::vector paramIds(static_cast(count)); + if (AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_ParameterList, + kAudioUnitScope_Global, 0, + paramIds.data(), &size) != noErr) + { + return; + } + + installParameterEventListener(); + + for (auto paramId : paramIds) + { + AudioUnitParameterInfo info{}; + UInt32 infoSize = sizeof(info); + if (AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_ParameterInfo, + kAudioUnitScope_Global, paramId, + &info, &infoSize) != noErr) + { + continue; + } + + const auto name = makeParameterName(info, paramId); + const auto range = makeParameterRange(info); + releaseParameterInfoStrings(info); + + auto param = AudioParameterBuilder() + .withID(name + "_" + String(paramId)) + .withName(name) + .withRange(range) + .withDefault(info.defaultValue) + .withSmoothing(0.0f) + .build(); + + param->addListener(this); + + auParameterIds.push_back(paramId); + addParameter(std::move(param)); + + addAudioUnitParameterListener(paramId); + } + + syncParameterValuesFromAudioUnit(); + } + + void installParameterEventListener() + { + if (eventListener != nullptr) + return; + + AUEventListenerCreate(parameterEventCallback, + this, + CFRunLoopGetMain(), + kCFRunLoopCommonModes, + 0.02f, + 0.01f, + &eventListener); + } + + void addAudioUnitParameterListener(AudioUnitParameterID paramId) + { + if (eventListener == nullptr) + return; + + const auto event = makeAUParameterEvent(audioUnit, paramId, kAudioUnitEvent_ParameterValueChange); + AUEventListenerAddEventType(eventListener, this, &event); + } + + void notifyAudioUnitParametersChanged() + { + if (audioUnit == nullptr) + return; + + for (auto paramId : auParameterIds) + { + const auto parameter = makeAUParameter(audioUnit, paramId); + AUParameterListenerNotify(eventListener, this, ¶meter); + } + } + + void syncParameterValuesFromAudioUnit() noexcept + { + const auto params = getParameters(); + const auto numParams = jmin(params.size(), auParameterIds.size()); + + for (std::size_t i = 0; i < numParams; ++i) + { + AudioUnitParameterValue value = 0.0f; + if (AudioUnitGetParameter(audioUnit, + auParameterIds[i], + kAudioUnitScope_Global, + 0, + &value) == noErr) + { + params[i]->setValue(static_cast(value)); + } + } + } + + void removeParameterListeners() + { + for (auto& param : getParameters()) + param->removeListener(this); + + if (eventListener != nullptr) + { + AUListenerDispose(eventListener); + eventListener = nullptr; + } + } + + AudioUnit audioUnit = nullptr; + AUEventListenerRef eventListener = nullptr; + int currentPreset = 0; + double renderSampleTime = 0.0; + std::vector auParameterIds; + AudioBuffer inputBuffer; + MidiBuffer outputMidiBuffer; + AudioBuffer* currentInputBuffer = nullptr; + MidiBuffer* currentMidiOutputBuffer = nullptr; + int currentInputNumChannels = 0; + int currentInputNumSamples = 0; + int preparedNumChannels = 0; + int preparedMaxBlockSize = 0; + std::vector outputABLStorage; +}; + +//============================================================================== + +AUv2Format::AUv2Format() = default; +AUv2Format::~AUv2Format() = default; + +AudioPluginFormatType AUv2Format::getFormatType() const +{ + return AudioPluginFormatType::audioUnit; +} + +String AUv2Format::getFormatName() const +{ + return "AUv2"; +} + +StringArray AUv2Format::getFileExtensions() const +{ + return {}; +} + +FileSearchPath AUv2Format::getDefaultSearchPaths() const +{ + return {}; +} + +ResultValue> AUv2Format::scanFile(const File&) +{ + // Scan by enumerating the AudioComponent registry (ignores file argument) + std::vector results; + + const OSType types[] = { + kAudioUnitType_MusicDevice, + kAudioUnitType_Effect, + kAudioUnitType_MusicEffect, + kAudioUnitType_Generator, + kAudioUnitType_MIDIProcessor}; + + for (OSType type : types) + { + AudioComponentDescription search{}; + search.componentType = type; + + AudioComponent comp = nullptr; + while ((comp = AudioComponentFindNext(comp, &search)) != nullptr) + { + AudioComponentDescription acd{}; + AudioComponentGetDescription(comp, &acd); + auto desc = descriptionFromComponent(comp, acd); + results.push_back(std::move(desc)); + } + } + + if (results.empty()) + return makeResultValueFail("No AudioComponents found in registry"); + + return makeResultValueOk(std::move(results)); +} + +ResultValue> AUv2Format::loadPlugin( + const AudioPluginDescription& description, + const AudioPluginHostContext& context) +{ + auto instance = AUv2Instance::create(description, context); + + if (instance == nullptr) + return makeResultValueFail("Failed to instantiate AUv2 plugin: " + description.name); + + return makeResultValueOk(std::move(instance)); +} + +} // namespace yup + +#endif // YUP_AUDIO_PLUGIN_HOST_ENABLE_AU && YUP_MAC diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp new file mode 100644 index 000000000..9497c71ad --- /dev/null +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp @@ -0,0 +1,804 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP + +namespace yup +{ + +namespace +{ + +//============================================================================== +struct CLAPModule +{ + CLAPModuleHandle handle = nullptr; + const clap_plugin_entry_t* entry = nullptr; + + static std::unique_ptr load (const File& file) + { + auto m = std::make_unique(); + m->handle = clapLoadModule (file.getFullPathName().toRawUTF8()); + + if (m->handle == nullptr) + return nullptr; + + m->entry = reinterpret_cast ( + clapGetAddress (m->handle, "clap_entry")); + + if (m->entry == nullptr || m->entry->init == nullptr) + { + clapUnloadModule (m->handle); + return nullptr; + } + + if (! m->entry->init (file.getFullPathName().toRawUTF8())) + { + clapUnloadModule (m->handle); + return nullptr; + } + + return m; + } + + ~CLAPModule() + { + if (entry != nullptr && entry->deinit != nullptr) + entry->deinit(); + + if (handle != nullptr) + clapUnloadModule (handle); + } +}; + +//============================================================================== +// Minimal clap_host_t implementation supplied to each plugin instance. +struct YUPCLAPHost +{ + clap_host_t host {}; + clap_host_note_ports_t notePorts {}; + String hostName; + String hostVendor; + String hostVersion; + + YUPCLAPHost (const AudioPluginHostContext& ctx) + : hostName (ctx.hostName) + , hostVendor (ctx.hostVendor) + , hostVersion (ctx.hostVersion) + { + host.clap_version = CLAP_VERSION; + host.host_data = this; + host.name = hostName.toRawUTF8(); + host.vendor = hostVendor.toRawUTF8(); + host.url = ""; + host.version = hostVersion.toRawUTF8(); + notePorts.supported_dialects = [] (const clap_host_t*) -> uint32_t + { + return CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI; + }; + notePorts.rescan = [] (const clap_host_t*, uint32_t) {}; + + host.get_extension = [] (const clap_host_t* host, const char* extensionId) -> const void* + { + auto* self = static_cast (host->host_data); + if (std::strcmp (extensionId, CLAP_EXT_NOTE_PORTS) == 0) + return &self->notePorts; + + return nullptr; + }; + host.request_restart = [] (const clap_host_t*) {}; + host.request_process = [] (const clap_host_t*) {}; + host.request_callback = [] (const clap_host_t*) {}; + } +}; + +struct CLAPInputEvents +{ + std::deque parameterEvents; + std::deque noteEvents; + std::deque midiEvents; + std::deque sysexEvents; + std::vector eventHeaders; + clap_input_events_t inputEvents {}; + + CLAPInputEvents() + { + inputEvents.ctx = this; + inputEvents.size = [] (const clap_input_events_t* events) -> uint32_t + { + auto* self = static_cast (events->ctx); + return static_cast (self->eventHeaders.size()); + }; + inputEvents.get = [] (const clap_input_events_t* events, uint32_t index) -> const clap_event_header_t* + { + auto* self = static_cast (events->ctx); + if (index >= self->eventHeaders.size()) + return nullptr; + + return self->eventHeaders[static_cast (index)]; + }; + } + + void clear() + { + parameterEvents.clear(); + noteEvents.clear(); + midiEvents.clear(); + sysexEvents.clear(); + eventHeaders.clear(); + } + + void addParameterValue (clap_id id, double value) + { + clap_event_param_value_t event {}; + event.header.size = sizeof (event); + event.header.time = 0; + event.header.space_id = CLAP_CORE_EVENT_SPACE_ID; + event.header.type = CLAP_EVENT_PARAM_VALUE; + event.param_id = id; + event.note_id = -1; + event.port_index = -1; + event.channel = -1; + event.key = -1; + event.value = value; + + parameterEvents.push_back (event); + eventHeaders.push_back (¶meterEvents.back().header); + } + + void addMidiMessage (const MidiMessageMetadata& metadata) + { + const auto message = metadata.getMessage(); + + if (message.isNoteOn() || message.isNoteOff()) + { + clap_event_note_t event {}; + event.header.size = sizeof (event); + event.header.time = static_cast (jmax (0, metadata.samplePosition)); + event.header.space_id = CLAP_CORE_EVENT_SPACE_ID; + event.header.type = message.isNoteOn() ? CLAP_EVENT_NOTE_ON : CLAP_EVENT_NOTE_OFF; + event.note_id = -1; + event.port_index = 0; + event.channel = static_cast (message.getChannel() - 1); + event.key = static_cast (message.getNoteNumber()); + event.velocity = static_cast (message.getFloatVelocity()); + + noteEvents.push_back (event); + eventHeaders.push_back (¬eEvents.back().header); + return; + } + + if (message.isSysEx()) + { + clap_event_midi_sysex_t event {}; + event.header.size = sizeof (event); + event.header.time = static_cast (jmax (0, metadata.samplePosition)); + event.header.space_id = CLAP_CORE_EVENT_SPACE_ID; + event.header.type = CLAP_EVENT_MIDI_SYSEX; + event.port_index = 0; + event.buffer = message.getSysExData(); + event.size = static_cast (message.getSysExDataSize()); + + sysexEvents.push_back (event); + eventHeaders.push_back (&sysexEvents.back().header); + return; + } + + if (metadata.numBytes > 0 && metadata.numBytes <= 3) + { + clap_event_midi_t event {}; + event.header.size = sizeof (event); + event.header.time = static_cast (jmax (0, metadata.samplePosition)); + event.header.space_id = CLAP_CORE_EVENT_SPACE_ID; + event.header.type = CLAP_EVENT_MIDI; + event.port_index = 0; + std::memcpy (event.data, metadata.data, static_cast (metadata.numBytes)); + + midiEvents.push_back (event); + eventHeaders.push_back (&midiEvents.back().header); + } + } +}; + +struct CLAPOutputEvents +{ + clap_output_events_t outputEvents {}; + MidiBuffer* midiOutput = nullptr; + + CLAPOutputEvents() + { + outputEvents.ctx = this; + outputEvents.try_push = [] (const clap_output_events_t* events, const clap_event_header_t* event) -> bool + { + auto* self = static_cast (events->ctx); + if (self == nullptr || self->midiOutput == nullptr || event == nullptr) + return false; + + self->addEventToMidiBuffer (*event); + return true; + }; + } + + void addEventToMidiBuffer (const clap_event_header_t& event) + { + if (event.space_id != CLAP_CORE_EVENT_SPACE_ID) + return; + + const int samplePosition = static_cast (event.time); + + if (event.type == CLAP_EVENT_NOTE_ON + || event.type == CLAP_EVENT_NOTE_OFF + || event.type == CLAP_EVENT_NOTE_CHOKE + || event.type == CLAP_EVENT_NOTE_END) + { + const auto& note = *reinterpret_cast (&event); + const int channel = note.channel >= 0 ? note.channel + 1 : 1; + const int key = note.key >= 0 ? note.key : 0; + const auto velocity = static_cast (note.velocity); + + midiOutput->addEvent (event.type == CLAP_EVENT_NOTE_ON + ? MidiMessage::noteOn (channel, key, velocity) + : MidiMessage::noteOff (channel, key, velocity), + samplePosition); + } + else if (event.type == CLAP_EVENT_MIDI) + { + const auto& midi = *reinterpret_cast (&event); + const int numBytes = MidiMessage::getMessageLengthFromFirstByte (midi.data[0]); + midiOutput->addEvent (midi.data, numBytes, samplePosition); + } + else if (event.type == CLAP_EVENT_MIDI_SYSEX) + { + const auto& sysex = *reinterpret_cast (&event); + midiOutput->addEvent (MidiMessage::createSysExMessage (sysex.buffer, static_cast (sysex.size)), + samplePosition); + } + } +}; + +} // namespace + +//============================================================================== + +class CLAPInstance : public AudioPluginInstance +{ +public: + CLAPInstance (const AudioPluginDescription& desc, + const AudioPluginHostContext& context, + std::unique_ptr module, + std::unique_ptr clapHost, + const clap_plugin_t* plugin) + : AudioPluginInstance (desc, buildBusLayout (plugin)) + , hostContext (context) + , clapModule (std::move (module)) + , yupHost (std::move (clapHost)) + , clapPlugin (plugin) + { + buildParameterList(); + setNonRealtime (context.isNonRealtime); + } + + ~CLAPInstance() override + { + releaseResources(); + + if (clapPlugin != nullptr) + { + clapPlugin->destroy (clapPlugin); + clapPlugin = nullptr; + } + } + + //============================================================================== + + void prepareToPlay (float sampleRate, int maxBlockSize) override + { + const int numChannels = jmax (2, pluginDescription.numInputChannels, pluginDescription.numOutputChannels); + preparedInPtrs.resize (static_cast (numChannels)); + preparedOutPtrs.resize (static_cast (numChannels)); + + clapPlugin->activate (clapPlugin, sampleRate, 1, static_cast (jmax (1, maxBlockSize))); + updateRenderMode(); + + clapPlugin->start_processing (clapPlugin); + } + + void releaseResources() override + { + if (clapPlugin != nullptr) + { + clapPlugin->stop_processing (clapPlugin); + clapPlugin->deactivate (clapPlugin); + } + } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override + { + ScopedNoDenormals noDenormals; + + if (isBypassed()) + { + processBlockBypassed (audioBuffer, midiBuffer); + return; + } + + const int numSamples = audioBuffer.getNumSamples(); + const int numChannels = audioBuffer.getNumChannels(); + + if (static_cast (preparedInPtrs.size()) < numChannels && numChannels > 0) + { + jassertfalse; + return; + } + + for (int c = 0; c < numChannels; ++c) + { + preparedInPtrs[static_cast (c)] = audioBuffer.getReadPointer (c); + preparedOutPtrs[static_cast (c)] = audioBuffer.getWritePointer (c); + } + + clap_audio_buffer_t inputBuf {}; + if (pluginDescription.numInputChannels > 0 && numChannels > 0) + { + inputBuf.data32 = const_cast (preparedInPtrs.data()); + inputBuf.channel_count = static_cast (numChannels); + } + inputBuf.latency = 0; + inputBuf.constant_mask = 0; + + clap_audio_buffer_t outputBuf {}; + if (pluginDescription.numOutputChannels > 0 && numChannels > 0) + { + outputBuf.data32 = preparedOutPtrs.data(); + outputBuf.channel_count = static_cast (numChannels); + } + + clap_process_t process {}; + process.steady_time = -1; + process.frames_count = static_cast (numSamples); + process.audio_inputs = inputBuf.channel_count > 0 ? &inputBuf : nullptr; + process.audio_inputs_count = inputBuf.channel_count > 0 ? 1 : 0; + process.audio_outputs = outputBuf.channel_count > 0 ? &outputBuf : nullptr; + process.audio_outputs_count = outputBuf.channel_count > 0 ? 1 : 0; + + clapInputEvents.clear(); + const auto params = getParameters(); + const auto numParams = yup::jmin (params.size(), clapParameterIds.size()); + + for (std::size_t i = 0; i < numParams; ++i) + clapInputEvents.addParameterValue (clapParameterIds[i], static_cast (params[i]->getValue())); + + for (const auto& metadata : midiBuffer) + clapInputEvents.addMidiMessage (metadata); + + process.in_events = &clapInputEvents.inputEvents; + process.out_events = &clapOutputEvents.outputEvents; + + outputMidiBuffer.clear(); + clapOutputEvents.midiOutput = &outputMidiBuffer; + + clapPlugin->process (clapPlugin, &process); + + clapOutputEvents.midiOutput = nullptr; + midiBuffer.clear(); + midiBuffer.addEvents (outputMidiBuffer, 0, -1, 0); + } + + //============================================================================== + + int getCurrentPreset() const noexcept override { return currentPreset; } + + void setCurrentPreset (int index) noexcept override + { + currentPreset = index; + } + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + //============================================================================== + + Result loadStateFromMemory (const MemoryBlock& memoryBlock) override + { + auto* stateExt = reinterpret_cast ( + clapPlugin->get_extension (clapPlugin, CLAP_EXT_STATE)); + + if (stateExt == nullptr) + return Result::fail ("Plugin does not support CLAP state extension"); + + struct MemStream + { + const MemoryBlock* block; + std::size_t position = 0; + }; + + MemStream ms { &memoryBlock }; + + clap_istream_t stream {}; + stream.ctx = &ms; + stream.read = [] (const clap_istream_t* s, void* buf, uint64_t size) -> int64_t + { + auto* ms = static_cast (s->ctx); + const std::size_t remaining = ms->block->getSize() - ms->position; + const std::size_t toRead = std::min (static_cast (size), remaining); + + std::memcpy (buf, + static_cast (ms->block->getData()) + ms->position, + toRead); + ms->position += toRead; + return static_cast (toRead); + }; + + return stateExt->load (clapPlugin, &stream) + ? Result::ok() + : Result::fail ("CLAP state load failed"); + } + + Result saveStateIntoMemory (MemoryBlock& memoryBlock) override + { + auto* stateExt = reinterpret_cast ( + clapPlugin->get_extension (clapPlugin, CLAP_EXT_STATE)); + + if (stateExt == nullptr) + return Result::fail ("Plugin does not support CLAP state extension"); + + memoryBlock.reset(); + + clap_ostream_t stream {}; + stream.ctx = &memoryBlock; + stream.write = [] (const clap_ostream_t* s, const void* buf, uint64_t size) -> int64_t + { + auto* block = static_cast (s->ctx); + block->append (buf, static_cast (size)); + return static_cast (size); + }; + + return stateExt->save (clapPlugin, &stream) + ? Result::ok() + : Result::fail ("CLAP state save failed"); + } + + //============================================================================== + + bool hasEditor() const override { return false; } + + //============================================================================== + + static std::unique_ptr create (const AudioPluginDescription& desc, + const AudioPluginHostContext& context) + { + auto mod = CLAPModule::load (File (desc.fileOrBundlePath)); + if (mod == nullptr) + return nullptr; + + const clap_plugin_factory_t* factory = reinterpret_cast ( + mod->entry->get_factory (CLAP_PLUGIN_FACTORY_ID)); + + if (factory == nullptr) + return nullptr; + + auto yupHost = std::make_unique (context); + + const uint32_t count = factory->get_plugin_count (factory); + for (uint32_t i = 0; i < count; ++i) + { + const clap_plugin_descriptor_t* plugDesc = factory->get_plugin_descriptor (factory, i); + if (plugDesc == nullptr) + continue; + + if (String (plugDesc->id) != desc.identifier) + continue; + + const clap_plugin_t* plugin = factory->create_plugin (factory, + &yupHost->host, + plugDesc->id); + if (plugin == nullptr) + continue; + + if (! plugin->init (plugin)) + { + plugin->destroy (plugin); + continue; + } + + return std::make_unique (desc, context, std::move (mod), std::move (yupHost), plugin); + } + + return nullptr; + } + +private: + static AudioBusLayout buildBusLayout (const clap_plugin_t* plugin) + { + std::vector inputs, outputs; + + auto* portsExt = reinterpret_cast ( + plugin->get_extension (plugin, CLAP_EXT_AUDIO_PORTS)); + + if (portsExt != nullptr) + { + const uint32_t numInputs = portsExt->count (plugin, true); + for (uint32_t i = 0; i < numInputs; ++i) + { + clap_audio_port_info_t info {}; + portsExt->get (plugin, i, true, &info); + inputs.emplace_back (String (info.name), AudioBus::Type::Audio, AudioBus::Direction::Input, static_cast (info.channel_count)); + } + + const uint32_t numOutputs = portsExt->count (plugin, false); + for (uint32_t i = 0; i < numOutputs; ++i) + { + clap_audio_port_info_t info {}; + portsExt->get (plugin, i, false, &info); + outputs.emplace_back (String (info.name), AudioBus::Type::Audio, AudioBus::Direction::Output, static_cast (info.channel_count)); + } + } + + auto* notePortsExt = reinterpret_cast ( + plugin->get_extension (plugin, CLAP_EXT_NOTE_PORTS)); + + if (notePortsExt != nullptr) + { + const uint32_t numInputs = notePortsExt->count (plugin, true); + for (uint32_t i = 0; i < numInputs; ++i) + { + clap_note_port_info_t info {}; + if (notePortsExt->get (plugin, i, true, &info)) + inputs.emplace_back (String (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Input, 1); + } + + const uint32_t numOutputs = notePortsExt->count (plugin, false); + for (uint32_t i = 0; i < numOutputs; ++i) + { + clap_note_port_info_t info {}; + if (notePortsExt->get (plugin, i, false, &info)) + outputs.emplace_back (String (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Output, 1); + } + } + + return AudioBusLayout (std::move (inputs), std::move (outputs)); + } + + void buildParameterList() + { + auto* paramsExt = reinterpret_cast ( + clapPlugin->get_extension (clapPlugin, CLAP_EXT_PARAMS)); + + if (paramsExt == nullptr) + return; + + const uint32_t count = paramsExt->count (clapPlugin); + for (uint32_t i = 0; i < count; ++i) + { + clap_param_info_t info {}; + if (! paramsExt->get_info (clapPlugin, i, &info)) + continue; + + auto param = AudioParameterBuilder() + .withID (String (static_cast (info.id))) + .withName (String (info.name)) + .withRange (static_cast (info.min_value), + static_cast (info.max_value)) + .withDefault (static_cast (info.default_value)) + .build(); + + clapParameterIds.push_back (info.id); + addParameter (std::move (param)); + } + } + + void updateRenderMode() + { + if (clapPlugin == nullptr) + return; + + auto* renderExt = reinterpret_cast ( + clapPlugin->get_extension (clapPlugin, CLAP_EXT_RENDER)); + + if (renderExt != nullptr && renderExt->set != nullptr) + renderExt->set (clapPlugin, isNonRealtime() ? CLAP_RENDER_OFFLINE : CLAP_RENDER_REALTIME); + } + + void nonRealtimeStateChanged() override + { + updateRenderMode(); + } + + AudioPluginHostContext hostContext; + std::unique_ptr clapModule; + std::unique_ptr yupHost; + const clap_plugin_t* clapPlugin = nullptr; + CLAPInputEvents clapInputEvents; + CLAPOutputEvents clapOutputEvents; + std::vector preparedInPtrs; + std::vector preparedOutPtrs; + MidiBuffer outputMidiBuffer; + std::vector clapParameterIds; + int currentPreset = 0; +}; + +//============================================================================== + +CLAPFormat::CLAPFormat() = default; +CLAPFormat::~CLAPFormat() = default; + +AudioPluginFormatType CLAPFormat::getFormatType() const +{ + return AudioPluginFormatType::clap; +} + +String CLAPFormat::getFormatName() const +{ + return "CLAP"; +} + +StringArray CLAPFormat::getFileExtensions() const +{ + return { ".clap" }; +} + +FileSearchPath CLAPFormat::getDefaultSearchPaths() const +{ + FileSearchPath paths; + +#if YUP_MAC + paths.add (File ("/Library/Audio/Plug-Ins/CLAP")); + paths.add (File::getSpecialLocation (File::userHomeDirectory).getChildFile ("Library/Audio/Plug-Ins/CLAP")); +#elif YUP_WINDOWS + if (const char* pf = getenv ("CommonProgramFiles")) + paths.add (File (String (pf) + "\\CLAP")); + if (const char* appdata = getenv ("APPDATA")) + paths.add (File (String (appdata) + "\\CLAP")); +#elif YUP_LINUX + paths.add (File ("/usr/lib/clap")); + paths.add (File ("/usr/local/lib/clap")); + paths.add (File::getSpecialLocation (File::userHomeDirectory).getChildFile (".clap")); +#endif + + return paths; +} + +ResultValue> CLAPFormat::scanFile (const File& file) +{ + if (file.getFileExtension().toLowerCase() != ".clap") + return makeResultValueFail ("Not a CLAP file"); + + auto mod = CLAPModule::load (file); + if (mod == nullptr) + return makeResultValueFail ("Failed to load CLAP module: " + file.getFullPathName()); + + const clap_plugin_factory_t* factory = reinterpret_cast ( + mod->entry->get_factory (CLAP_PLUGIN_FACTORY_ID)); + + if (factory == nullptr) + return makeResultValueFail ("No plugin factory in: " + file.getFullPathName()); + + std::vector results; + const uint32_t count = factory->get_plugin_count (factory); + + for (uint32_t i = 0; i < count; ++i) + { + const clap_plugin_descriptor_t* plugDesc = factory->get_plugin_descriptor (factory, i); + if (plugDesc == nullptr) + continue; + + AudioPluginDescription desc; + desc.formatType = AudioPluginFormatType::clap; + desc.fileOrBundlePath = file.getFullPathName(); + desc.name = String (plugDesc->name); + desc.vendor = String (plugDesc->vendor); + desc.version = String (plugDesc->version); + desc.identifier = String (plugDesc->id); + + if (plugDesc->features != nullptr) + { + for (int j = 0; plugDesc->features[j] != nullptr; ++j) + { + const String feature (plugDesc->features[j]); + if (feature == CLAP_PLUGIN_FEATURE_INSTRUMENT) + desc.isInstrument = true; + if (feature == CLAP_PLUGIN_FEATURE_AUDIO_EFFECT) + desc.isEffect = true; + } + } + + // Briefly instantiate the plugin to collect channel counts + { + clap_host_t host {}; + host.clap_version = CLAP_VERSION; + host.host_data = nullptr; + host.name = "YUP Scanner"; + host.vendor = "yup"; + host.url = ""; + host.version = "1.0.0"; + host.get_extension = [] (const clap_host_t*, const char*) -> const void* + { + return nullptr; + }; + host.request_restart = [] (const clap_host_t*) {}; + host.request_process = [] (const clap_host_t*) {}; + host.request_callback = [] (const clap_host_t*) {}; + + const clap_plugin_t* plugin = factory->create_plugin (factory, &host, plugDesc->id); + if (plugin != nullptr && plugin->init (plugin)) + { + auto* portsExt = reinterpret_cast ( + plugin->get_extension (plugin, CLAP_EXT_AUDIO_PORTS)); + + if (portsExt != nullptr) + { + const uint32_t numInputs = portsExt->count (plugin, true); + for (uint32_t p = 0; p < numInputs; ++p) + { + clap_audio_port_info_t info {}; + if (portsExt->get (plugin, p, true, &info)) + desc.numInputChannels += static_cast (info.channel_count); + } + + const uint32_t numOutputs = portsExt->count (plugin, false); + for (uint32_t p = 0; p < numOutputs; ++p) + { + clap_audio_port_info_t info {}; + if (portsExt->get (plugin, p, false, &info)) + desc.numOutputChannels += static_cast (info.channel_count); + } + } + + auto* notePortsExt = reinterpret_cast ( + plugin->get_extension (plugin, CLAP_EXT_NOTE_PORTS)); + + if (notePortsExt != nullptr) + { + desc.numMidiInputPorts = static_cast (notePortsExt->count (plugin, true)); + desc.numMidiOutputPorts = static_cast (notePortsExt->count (plugin, false)); + } + + plugin->destroy (plugin); + } + } + + results.push_back (std::move (desc)); + } + + if (results.empty()) + return makeResultValueFail ("No plugins found in: " + file.getFullPathName()); + + return makeResultValueOk (std::move (results)); +} + +ResultValue> CLAPFormat::loadPlugin ( + const AudioPluginDescription& description, + const AudioPluginHostContext& context) +{ + auto instance = CLAPInstance::create (description, context); + + if (instance == nullptr) + return makeResultValueFail ("Failed to load CLAP plugin: " + description.name); + + return makeResultValueOk (std::move (instance)); +} + +} // namespace yup + +#endif // YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.h b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.h new file mode 100644 index 000000000..94d6a37bb --- /dev/null +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.h @@ -0,0 +1,54 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP + +namespace yup +{ + +/** + AudioPluginFormat implementation for CLAP. + + Scans .clap bundles by loading the shared library and enumerating the + clap_plugin_entry factory. Loads plugins via clap_host_t + clap_plugin_t. +*/ +class CLAPFormat : public AudioPluginFormat +{ +public: + CLAPFormat(); + ~CLAPFormat() override; + + AudioPluginFormatType getFormatType() const override; + String getFormatName() const override; + StringArray getFileExtensions() const override; + + FileSearchPath getDefaultSearchPaths() const override; + + ResultValue> scanFile (const File& file) override; + + ResultValue> loadPlugin ( + const AudioPluginDescription& description, + const AudioPluginHostContext& context) override; +}; + +} // namespace yup + +#endif // YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp new file mode 100644 index 000000000..a56b37e5e --- /dev/null +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp @@ -0,0 +1,1638 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3 + +#if YUP_MAC +#include +#endif + +namespace yup +{ + +using namespace Steinberg; + +namespace +{ + +#if YUP_MAC +template +struct VST3CFObjectDeleter +{ + void operator() (CFType object) const noexcept + { + if (object != nullptr) + CFRelease (object); + } +}; + +template +using VST3CFUniquePtr = std::unique_ptr, VST3CFObjectDeleter>; +#endif + +//============================================================================== +// Converts a Steinberg TChar (UTF-16) string to a yup::String. +String toString (const Vst::TChar* src) +{ + return String (CharPointer_UTF16 (reinterpret_cast (src))); +} + +String classIDToString (const TUID& cid) +{ + return String::toHexString (cid, static_cast (sizeof (TUID))); +} + +void toString128 (const String& source, Vst::String128 destination) +{ + if (destination == nullptr) + return; + + const auto utf16 = source.toUTF16(); + const auto length = jmin (static_cast (127), utf16.length()); + + std::memcpy (destination, utf16.getAddress(), length * sizeof (Vst::TChar)); + destination[length] = 0; +} + +void addMidiMessageToVST3Events (Vst::EventList& events, const MidiMessageMetadata& metadata) +{ + const auto message = metadata.getMessage(); + + Vst::Event event {}; + event.busIndex = 0; + event.sampleOffset = metadata.samplePosition; + + if (message.isNoteOn()) + { + event.type = Vst::Event::kNoteOnEvent; + event.noteOn.channel = static_cast (message.getChannel() - 1); + event.noteOn.pitch = static_cast (message.getNoteNumber()); + event.noteOn.velocity = message.getFloatVelocity(); + event.noteOn.length = 0; + event.noteOn.noteId = -1; + events.addEvent (event); + return; + } + + if (message.isNoteOff()) + { + event.type = Vst::Event::kNoteOffEvent; + event.noteOff.channel = static_cast (message.getChannel() - 1); + event.noteOff.pitch = static_cast (message.getNoteNumber()); + event.noteOff.velocity = message.getFloatVelocity(); + event.noteOff.noteId = -1; + events.addEvent (event); + return; + } + + if (message.isAftertouch()) + { + event.type = Vst::Event::kPolyPressureEvent; + event.polyPressure.channel = static_cast (message.getChannel() - 1); + event.polyPressure.pitch = static_cast (message.getNoteNumber()); + event.polyPressure.pressure = static_cast (message.getAfterTouchValue()) / 127.0f; + event.polyPressure.noteId = -1; + events.addEvent (event); + return; + } + + if (message.isSysEx()) + { + event.type = Vst::Event::kDataEvent; + event.data.type = Vst::DataEvent::kMidiSysEx; + event.data.bytes = message.getSysExData(); + event.data.size = static_cast (message.getSysExDataSize()); + events.addEvent (event); + return; + } + + event.type = Vst::Event::kLegacyMIDICCOutEvent; + event.midiCCOut.channel = static_cast (jlimit (0, 15, message.getChannel() - 1)); + + if (message.isController()) + { + event.midiCCOut.controlNumber = static_cast (message.getControllerNumber()); + event.midiCCOut.value = static_cast (message.getControllerValue()); + events.addEvent (event); + } + else if (message.isProgramChange()) + { + event.midiCCOut.controlNumber = static_cast (Vst::kCtrlProgramChange); + event.midiCCOut.value = static_cast (message.getProgramChangeNumber()); + events.addEvent (event); + } + else if (message.isPitchWheel()) + { + const auto value = jlimit (0, 0x3fff, message.getPitchWheelValue()); + event.midiCCOut.controlNumber = static_cast (Vst::kPitchBend); + event.midiCCOut.value = static_cast (value & 0x7f); + event.midiCCOut.value2 = static_cast ((value >> 7) & 0x7f); + events.addEvent (event); + } + else if (message.isChannelPressure()) + { + event.midiCCOut.controlNumber = static_cast (Vst::kAfterTouch); + event.midiCCOut.value = static_cast (message.getChannelPressureValue()); + events.addEvent (event); + } +} + +void addVST3EventToMidiBuffer (const Vst::Event& event, MidiBuffer& midiBuffer) +{ + switch (event.type) + { + case Vst::Event::kNoteOnEvent: + midiBuffer.addEvent (MidiMessage::noteOn (event.noteOn.channel + 1, + event.noteOn.pitch, + event.noteOn.velocity), + event.sampleOffset); + break; + + case Vst::Event::kNoteOffEvent: + midiBuffer.addEvent (MidiMessage::noteOff (event.noteOff.channel + 1, + event.noteOff.pitch, + event.noteOff.velocity), + event.sampleOffset); + break; + + case Vst::Event::kPolyPressureEvent: + midiBuffer.addEvent (MidiMessage::aftertouchChange (event.polyPressure.channel + 1, + event.polyPressure.pitch, + jlimit (0, 127, static_cast (event.polyPressure.pressure * 127.0f))), + event.sampleOffset); + break; + + case Vst::Event::kDataEvent: + if (event.data.type == Vst::DataEvent::kMidiSysEx) + midiBuffer.addEvent (MidiMessage::createSysExMessage (event.data.bytes, static_cast (event.data.size)), + event.sampleOffset); + break; + + case Vst::Event::kLegacyMIDICCOutEvent: + { + const int channel = event.midiCCOut.channel + 1; + + if (event.midiCCOut.controlNumber <= 127) + { + midiBuffer.addEvent (MidiMessage::controllerEvent (channel, + event.midiCCOut.controlNumber, + static_cast (event.midiCCOut.value)), + event.sampleOffset); + } + else if (event.midiCCOut.controlNumber == Vst::kCtrlProgramChange) + { + midiBuffer.addEvent (MidiMessage::programChange (channel, static_cast (event.midiCCOut.value)), + event.sampleOffset); + } + else if (event.midiCCOut.controlNumber == Vst::kPitchBend) + { + midiBuffer.addEvent (MidiMessage::pitchWheel (channel, + (static_cast (event.midiCCOut.value2) << 7) + | static_cast (event.midiCCOut.value)), + event.sampleOffset); + } + else if (event.midiCCOut.controlNumber == Vst::kAfterTouch) + { + midiBuffer.addEvent (MidiMessage::channelPressureChange (channel, static_cast (event.midiCCOut.value)), + event.sampleOffset); + } + else if (event.midiCCOut.controlNumber == Vst::kCtrlPolyPressure) + { + midiBuffer.addEvent (MidiMessage::aftertouchChange (channel, + static_cast (event.midiCCOut.value), + static_cast (event.midiCCOut.value2)), + event.sampleOffset); + } + + break; + } + + default: + break; + } +} + +//============================================================================== +class HostAttributeList final : public Vst::IAttributeList +{ +public: + HostAttributeList() + { + FUNKNOWN_CTOR + } + + ~HostAttributeList() noexcept { + FUNKNOWN_DTOR + } + + tresult PLUGIN_API setInt (Vst::IAttributeList::AttrID id, int64 value) override + { + if (id == nullptr) + return kInvalidArgument; + + attributes[std::string (id)] = Attribute (value); + return kResultTrue; + } + + tresult PLUGIN_API getInt (Vst::IAttributeList::AttrID id, int64& value) override + { + if (const auto* attribute = findAttribute (id, Attribute::Type::integer)) + { + value = attribute->intValue; + return kResultTrue; + } + + return kResultFalse; + } + + tresult PLUGIN_API setFloat (Vst::IAttributeList::AttrID id, double value) override + { + if (id == nullptr) + return kInvalidArgument; + + attributes[std::string (id)] = Attribute (value); + return kResultTrue; + } + + tresult PLUGIN_API getFloat (Vst::IAttributeList::AttrID id, double& value) override + { + if (const auto* attribute = findAttribute (id, Attribute::Type::floatingPoint)) + { + value = attribute->floatValue; + return kResultTrue; + } + + return kResultFalse; + } + + tresult PLUGIN_API setString (Vst::IAttributeList::AttrID id, const Vst::TChar* value) override + { + if (id == nullptr || value == nullptr) + return kInvalidArgument; + + Attribute attribute; + attribute.type = Attribute::Type::string; + + while (value[attribute.stringValue.size()] != 0) + attribute.stringValue.push_back (value[attribute.stringValue.size()]); + + attribute.stringValue.push_back (0); + attributes[std::string (id)] = std::move (attribute); + return kResultTrue; + } + + tresult PLUGIN_API getString (Vst::IAttributeList::AttrID id, Vst::TChar* value, uint32 sizeInBytes) override + { + if (value == nullptr) + return kInvalidArgument; + + if (const auto* attribute = findAttribute (id, Attribute::Type::string)) + { + const auto bytesToCopy = jmin (sizeInBytes, + static_cast (attribute->stringValue.size() * sizeof (Vst::TChar))); + std::memcpy (value, attribute->stringValue.data(), bytesToCopy); + return kResultTrue; + } + + return kResultFalse; + } + + tresult PLUGIN_API setBinary (Vst::IAttributeList::AttrID id, const void* data, uint32 sizeInBytes) override + { + if (id == nullptr || (data == nullptr && sizeInBytes > 0)) + return kInvalidArgument; + + Attribute attribute; + attribute.type = Attribute::Type::binary; + attribute.binaryValue.replaceAll (data, static_cast (sizeInBytes)); + attributes[std::string (id)] = std::move (attribute); + return kResultTrue; + } + + tresult PLUGIN_API getBinary (Vst::IAttributeList::AttrID id, const void*& data, uint32& sizeInBytes) override + { + if (const auto* attribute = findAttribute (id, Attribute::Type::binary)) + { + data = attribute->binaryValue.getData(); + sizeInBytes = static_cast (attribute->binaryValue.getSize()); + return kResultTrue; + } + + data = nullptr; + sizeInBytes = 0; + return kResultFalse; + } + + DECLARE_FUNKNOWN_METHODS + +private: + struct Attribute + { + enum class Type + { + integer, + floatingPoint, + string, + binary + }; + + Attribute() = default; + + explicit Attribute (int64 value) + : type (Type::integer) + , intValue (value) + { + } + + explicit Attribute (double value) + : type (Type::floatingPoint) + , floatValue (value) + { + } + + Type type = Type::integer; + int64 intValue = 0; + double floatValue = 0.0; + std::vector stringValue; + MemoryBlock binaryValue; + }; + + const Attribute* findAttribute (Vst::IAttributeList::AttrID id, Attribute::Type type) const + { + if (id == nullptr) + return nullptr; + + const auto iter = attributes.find (std::string (id)); + if (iter == attributes.end() || iter->second.type != type) + return nullptr; + + return std::addressof (iter->second); + } + + std::map attributes; +}; + +IMPLEMENT_FUNKNOWN_METHODS (HostAttributeList, Vst::IAttributeList, Vst::IAttributeList::iid) + +//============================================================================== +class HostMessage final : public Vst::IMessage +{ +public: + HostMessage() + { + FUNKNOWN_CTOR + } + + ~HostMessage() noexcept { + FUNKNOWN_DTOR + } + + FIDString PLUGIN_API getMessageID() override + { + return messageId.c_str(); + } + + void PLUGIN_API setMessageID (FIDString id) override + { + messageId = id != nullptr ? id : ""; + } + + Vst::IAttributeList* PLUGIN_API getAttributes() override + { + if (attributes == nullptr) + attributes = IPtr::adopt (new HostAttributeList()); + + return attributes.get(); + } + + DECLARE_FUNKNOWN_METHODS + +private: + std::string messageId; + IPtr attributes; +}; + +IMPLEMENT_FUNKNOWN_METHODS (HostMessage, Vst::IMessage, Vst::IMessage::iid) + +//============================================================================== +class HostApplication final : public Vst::IHostApplication +{ +public: + explicit HostApplication (const AudioPluginHostContext& context) + : hostName (context.hostName.isNotEmpty() ? context.hostName : "YUP") + { + FUNKNOWN_CTOR + } + + ~HostApplication() noexcept { + FUNKNOWN_DTOR + } + + tresult PLUGIN_API getName (Vst::String128 name) override + { + toString128 (hostName, name); + return kResultTrue; + } + + tresult PLUGIN_API createInstance (TUID cid, TUID iid, void** object) override + { + if (object == nullptr) + return kInvalidArgument; + + if (FUnknownPrivate::iidEqual (cid, Vst::IMessage::iid) + && FUnknownPrivate::iidEqual (iid, Vst::IMessage::iid)) + { + *object = new HostMessage(); + return kResultTrue; + } + + if (FUnknownPrivate::iidEqual (cid, Vst::IAttributeList::iid) + && FUnknownPrivate::iidEqual (iid, Vst::IAttributeList::iid)) + { + *object = new HostAttributeList(); + return kResultTrue; + } + + *object = nullptr; + return kResultFalse; + } + + DECLARE_FUNKNOWN_METHODS + +private: + String hostName; +}; + +IMPLEMENT_FUNKNOWN_METHODS (HostApplication, Vst::IHostApplication, Vst::IHostApplication::iid) + +//============================================================================== +// RAII wrapper around a dynamically loaded VST3 module. +struct VST3Module +{ + DynamicLibrary library; + +#if YUP_MAC + using BundleEntryFn = bool (*) (CFBundleRef); + using BundleExitFn = bool (*)(); + + VST3CFUniquePtr bundle; + BundleExitFn bundleExit = nullptr; + bool bundleEntryCalled = false; +#endif + + using FactoryFn = IPluginFactory*(PLUGIN_API*) (); + FactoryFn getFactory = nullptr; + +#if YUP_MAC + static VST3CFUniquePtr createBundle (const File& file) + { + const auto path = file.getFullPathName(); + VST3CFUniquePtr url (CFURLCreateFromFileSystemRepresentation (kCFAllocatorDefault, + reinterpret_cast (path.toRawUTF8()), + static_cast (path.getNumBytesAsUTF8()), + true)); + + if (url == nullptr) + return {}; + + return VST3CFUniquePtr (CFBundleCreate (kCFAllocatorDefault, url.get())); + } + + static File getBundleExecutableFile (CFBundleRef bundleToUse, const File& bundleFile) + { + const auto macOSFolder = bundleFile.getChildFile ("Contents/MacOS"); + + if (bundleToUse != nullptr) + { + auto* executableValue = CFBundleGetValueForInfoDictionaryKey (bundleToUse, kCFBundleExecutableKey); + + if (executableValue != nullptr && CFGetTypeID (executableValue) == CFStringGetTypeID()) + return macOSFolder.getChildFile (String::fromCFString (static_cast (executableValue))); + } + + return macOSFolder.getChildFile (bundleFile.getFileNameWithoutExtension()); + } +#endif + + static std::unique_ptr load (const File& file) + { + auto m = std::make_unique(); + auto libraryFile = file; + +#if YUP_MAC + if (file.isDirectory()) + { + m->bundle = createBundle (file); + + if (m->bundle == nullptr) + return nullptr; + + libraryFile = getBundleExecutableFile (m->bundle.get(), file); + } +#endif + + if (! m->library.open (libraryFile.getFullPathName())) + return nullptr; + +#if YUP_MAC + if (m->bundle != nullptr) + { + auto bundleEntry = reinterpret_cast (m->library.getFunction ("bundleEntry")); + m->bundleExit = reinterpret_cast (m->library.getFunction ("bundleExit")); + + if (bundleEntry == nullptr || m->bundleExit == nullptr) + return nullptr; + + if (! bundleEntry (m->bundle.get())) + return nullptr; + + m->bundleEntryCalled = true; + } +#endif + + m->getFactory = reinterpret_cast ( + m->library.getFunction ("GetPluginFactory")); + + if (m->getFactory == nullptr) + return nullptr; + + return m; + } + + ~VST3Module() + { +#if YUP_MAC + if (bundleEntryCalled && bundleExit != nullptr) + bundleExit(); +#endif + } +}; + +//============================================================================== +// Minimal IComponentHandler stub — required by IAudioProcessor::initialize(). +class HostComponentHandler : public Vst::IComponentHandler +{ +public: + HostComponentHandler() + { + FUNKNOWN_CTOR + } + + ~HostComponentHandler() noexcept { + FUNKNOWN_DTOR + } + + tresult PLUGIN_API beginEdit (Vst::ParamID) override + { + return kResultOk; + } + + tresult PLUGIN_API performEdit (Vst::ParamID, Vst::ParamValue) override { return kResultOk; } + + tresult PLUGIN_API endEdit (Vst::ParamID) override { return kResultOk; } + + tresult PLUGIN_API restartComponent (int32) override { return kResultOk; } + + DECLARE_FUNKNOWN_METHODS +}; + +IMPLEMENT_FUNKNOWN_METHODS (HostComponentHandler, Vst::IComponentHandler, Vst::IComponentHandler::iid) + +//============================================================================== +FIDString getVST3PlatformType() +{ +#if YUP_WINDOWS + return kPlatformTypeHWND; +#elif YUP_MAC + return kPlatformTypeNSView; +#elif YUP_LINUX + return kPlatformTypeX11EmbedWindowID; +#else + return nullptr; +#endif +} + +#if YUP_MAC +void* getVST3ParentViewFromNativeHandle (void* nativeHandle) +{ + if (nativeHandle == nullptr) + return nullptr; + + id nativeObject = (__bridge id) nativeHandle; + if ([nativeObject isKindOfClass:[NSWindow class]]) + return (__bridge void*) [(NSWindow*) nativeObject contentView]; + + if ([nativeObject isKindOfClass:[NSView class]]) + return nativeHandle; + + return nullptr; +} +#endif + +class VST3Editor; + +class VST3EditorFrame final : public IPlugFrame +{ +public: + explicit VST3EditorFrame (VST3Editor& ownerToUse) + : owner (std::addressof (ownerToUse)) + { + FUNKNOWN_CTOR + } + + ~VST3EditorFrame() noexcept { + FUNKNOWN_DTOR + } + + tresult PLUGIN_API resizeView (IPlugView* view, ViewRect* newSize) override; + + void clearOwner() noexcept { owner = nullptr; } + + DECLARE_FUNKNOWN_METHODS + +private: + VST3Editor* owner = nullptr; +}; + +IMPLEMENT_FUNKNOWN_METHODS (VST3EditorFrame, IPlugFrame, IPlugFrame::iid) + +class VST3Editor final : public AudioProcessorEditor +{ +public: + static std::unique_ptr create (Vst::IEditController* controller) + { + if (controller == nullptr) + return nullptr; + + IPlugView* rawView = controller->createView (Vst::ViewType::kEditor); + if (rawView == nullptr) + return nullptr; + + IPtr view = IPtr::adopt (rawView); + const auto* platformType = getVST3PlatformType(); + + if (platformType == nullptr || view->isPlatformTypeSupported (platformType) != kResultTrue) + return nullptr; + + return std::unique_ptr (new VST3Editor (std::move (view))); + } + + ~VST3Editor() override + { + detachPlugView(); + + if (frame != nullptr) + { + static_cast (frame.get())->clearOwner(); + frame = nullptr; + } + + plugView = nullptr; + } + + bool isResizable() const override + { + return plugView != nullptr && plugView->canResize() == kResultTrue; + } + + Size getPreferredSize() const override { return preferredSize; } + + void paint (Graphics& g) override + { + g.setFillColor (Color (0xff101417)); + g.fillAll(); + } + + void resized() override + { + resizePlugViewToBounds(); + } + + void attachedToNative() override + { + attachPlugView(); + } + + void detachedFromNative() override + { + detachPlugView(); + } + + tresult handleResizeRequest (IPlugView* view, ViewRect* newSize) + { + if (plugView == nullptr || view != plugView.get() || newSize == nullptr) + return kInvalidArgument; + + preferredSize = { + jmax (1, static_cast (newSize->getWidth())), + jmax (1, static_cast (newSize->getHeight())) + }; + + newSize->right = newSize->left + preferredSize.getWidth(); + newSize->bottom = newSize->top + preferredSize.getHeight(); + + if (auto* topLevel = getTopLevelComponent()) + topLevel->setSize (preferredSize.to()); + else + setSize (preferredSize.to()); + + plugView->onSize (newSize); + return kResultTrue; + } + +private: + explicit VST3Editor (IPtr view) + : plugView (std::move (view)) + , frame (IPtr::adopt (new VST3EditorFrame (*this))) + { + ViewRect size; + if (plugView->getSize (std::addressof (size)) == kResultTrue + && size.getWidth() > 0 + && size.getHeight() > 0) + { + preferredSize = { + jmax (320, static_cast (size.getWidth())), + jmax (240, static_cast (size.getHeight())) + }; + } + + setSize (preferredSize.to()); + } + + void attachPlugView() + { + if (plugView == nullptr || attached) + return; + + auto* nativeComponent = getNativeComponent(); + if (nativeComponent == nullptr) + return; + + void* parentHandle = nativeComponent->getNativeHandle(); + +#if YUP_MAC + parentHandle = getVST3ParentViewFromNativeHandle (parentHandle); +#endif + + const auto* platformType = getVST3PlatformType(); + if (parentHandle == nullptr || platformType == nullptr) + return; + + if (plugView->setFrame (frame.get()) != kResultTrue) + return; + + if (plugView->attached (parentHandle, platformType) != kResultTrue) + { + plugView->setFrame (nullptr); + return; + } + + attached = true; + resizePlugViewToBounds(); + } + + void detachPlugView() + { + if (plugView == nullptr) + return; + + if (attached) + { + plugView->removed(); + attached = false; + } + + plugView->setFrame (nullptr); + } + + void resizePlugViewToBounds() + { + if (plugView == nullptr || ! attached) + return; + + const auto bounds = getBoundsRelativeToTopLevelComponent(); + ViewRect size; + size.left = static_cast (bounds.getX()); + size.top = static_cast (bounds.getY()); + size.right = size.left + jmax (1, static_cast (bounds.getWidth())); + size.bottom = size.top + jmax (1, static_cast (bounds.getHeight())); + + plugView->onSize (std::addressof (size)); + } + + IPtr plugView; + IPtr frame; + Size preferredSize { 640, 480 }; + bool attached = false; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (VST3Editor) +}; + +tresult PLUGIN_API VST3EditorFrame::resizeView (IPlugView* view, ViewRect* newSize) +{ + if (owner == nullptr) + return kResultFalse; + + return owner->handleResizeRequest (view, newSize); +} + +} // namespace + +//============================================================================== + +class VST3Instance : public AudioPluginInstance +{ +public: + VST3Instance (const AudioPluginDescription& desc, + const AudioPluginHostContext& context, + std::unique_ptr module, + IPtr hostApplication, + IPtr componentHandler, + IPtr component, + IPtr processor, + IPtr controller, + bool controllerWasInitialized) + : AudioPluginInstance (desc, + buildBusLayout (component.get())) + , hostContext (context) + , vst3Module (std::move (module)) + , vst3HostApplication (std::move (hostApplication)) + , vst3ComponentHandler (std::move (componentHandler)) + , vst3Component (std::move (component)) + , vst3Processor (std::move (processor)) + , vst3Controller (std::move (controller)) + , vst3ControllerInitialized (controllerWasInitialized) + { + connectComponentAndController(); + buildParameterList(); + setNonRealtime (context.isNonRealtime); + } + + ~VST3Instance() override + { + disconnectComponentAndController(); + releaseResources(); + + if (vst3Controller != nullptr) + { + vst3Controller->setComponentHandler (nullptr); + + if (vst3ControllerInitialized) + vst3Controller->terminate(); + } + + vst3Processor = nullptr; + vst3Controller = nullptr; + vst3Component->terminate(); + vst3Component = nullptr; + vst3ComponentHandler = nullptr; + vst3HostApplication = nullptr; + } + + //============================================================================== + + void prepareToPlay (float sampleRate, int maxBlockSize) override + { + Vst::ProcessSetup setup; + setup.processMode = isNonRealtime() ? Vst::kOffline : Vst::kRealtime; + setProcessingPrecision (hostContext.preferDoublePrecision && supportsDoublePrecisionProcessing() + ? ProcessingPrecision::doublePrecision + : ProcessingPrecision::singlePrecision); + + setup.symbolicSampleSize = isUsingDoublePrecision() ? Vst::kSample64 : Vst::kSample32; + setup.maxSamplesPerBlock = maxBlockSize; + setup.sampleRate = sampleRate; + + const int numInputs = vst3Component->getBusCount (Vst::kAudio, Vst::kInput); + const int numOutputs = vst3Component->getBusCount (Vst::kAudio, Vst::kOutput); + + if (isUsingDoublePrecision()) + { + const int numChannels = jmax (1, numInputs, numOutputs); + doublePrecisionBuffer.setSize (numChannels, maxBlockSize, false, true, false); + } + + vst3Processor->setupProcessing (setup); + processingPrepared = true; + + for (int i = 0; i < numInputs; ++i) + vst3Component->activateBus (Vst::kAudio, Vst::kInput, i, true); + + for (int i = 0; i < numOutputs; ++i) + vst3Component->activateBus (Vst::kAudio, Vst::kOutput, i, true); + + const int numEventInputs = vst3Component->getBusCount (Vst::kEvent, Vst::kInput); + for (int i = 0; i < numEventInputs; ++i) + vst3Component->activateBus (Vst::kEvent, Vst::kInput, i, true); + + const int numEventOutputs = vst3Component->getBusCount (Vst::kEvent, Vst::kOutput); + for (int i = 0; i < numEventOutputs; ++i) + vst3Component->activateBus (Vst::kEvent, Vst::kOutput, i, true); + + vst3Component->setActive (true); + vst3Processor->setProcessing (true); + } + + void releaseResources() override + { + if (vst3Processor != nullptr) + vst3Processor->setProcessing (false); + + if (vst3Component != nullptr) + vst3Component->setActive (false); + + processingPrepared = false; + } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override + { + ScopedNoDenormals noDenormals; + + if (isBypassed()) + { + processBlockBypassed (audioBuffer, midiBuffer); + return; + } + + if (isUsingDoublePrecision()) + { + doublePrecisionBuffer.makeCopyOf (audioBuffer, true); + processBlock (doublePrecisionBuffer, midiBuffer); + + const int numChannels = jmin (audioBuffer.getNumChannels(), doublePrecisionBuffer.getNumChannels()); + const int numSamples = jmin (audioBuffer.getNumSamples(), doublePrecisionBuffer.getNumSamples()); + + for (int channel = 0; channel < numChannels; ++channel) + { + auto* destination = audioBuffer.getWritePointer (channel); + const auto* source = doublePrecisionBuffer.getReadPointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + destination[sample] = static_cast (source[sample]); + } + + return; + } + + Vst::ProcessData data {}; + prepareProcessData (data, audioBuffer.getNumSamples(), Vst::kSample32); + prepareMidiInputEvents (midiBuffer); + + // Input busses + Vst::AudioBusBuffers inputBus {}; + if (vst3Component->getBusCount (Vst::kAudio, Vst::kInput) > 0 && audioBuffer.getNumChannels() > 0) + { + inputBus.numChannels = audioBuffer.getNumChannels(); + inputBus.channelBuffers32 = const_cast (audioBuffer.getArrayOfReadPointers()); + data.inputs = &inputBus; + data.numInputs = 1; + } + + // Output busses + Vst::AudioBusBuffers outputBus {}; + if (vst3Component->getBusCount (Vst::kAudio, Vst::kOutput) > 0 && audioBuffer.getNumChannels() > 0) + { + outputBus.numChannels = audioBuffer.getNumChannels(); + outputBus.channelBuffers32 = const_cast (audioBuffer.getArrayOfWritePointers()); + data.outputs = &outputBus; + data.numOutputs = 1; + } + + vst3Processor->process (data); + + collectOutputEvents (midiBuffer); + } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override + { + ScopedNoDenormals noDenormals; + + if (isBypassed()) + { + processBlockBypassed (audioBuffer, midiBuffer); + return; + } + + if (! isUsingDoublePrecision()) + { + jassertfalse; + audioBuffer.clear(); + midiBuffer.clear(); + return; + } + + Vst::ProcessData data {}; + prepareProcessData (data, audioBuffer.getNumSamples(), Vst::kSample64); + prepareMidiInputEvents (midiBuffer); + + Vst::AudioBusBuffers inputBus {}; + if (vst3Component->getBusCount (Vst::kAudio, Vst::kInput) > 0 && audioBuffer.getNumChannels() > 0) + { + inputBus.numChannels = audioBuffer.getNumChannels(); + inputBus.channelBuffers64 = const_cast (audioBuffer.getArrayOfReadPointers()); + data.inputs = &inputBus; + data.numInputs = 1; + } + + Vst::AudioBusBuffers outputBus {}; + if (vst3Component->getBusCount (Vst::kAudio, Vst::kOutput) > 0 && audioBuffer.getNumChannels() > 0) + { + outputBus.numChannels = audioBuffer.getNumChannels(); + outputBus.channelBuffers64 = const_cast (audioBuffer.getArrayOfWritePointers()); + data.outputs = &outputBus; + data.numOutputs = 1; + } + + vst3Processor->process (data); + + collectOutputEvents (midiBuffer); + } + + bool supportsDoublePrecisionProcessing() const override + { + return vst3Processor != nullptr && vst3Processor->canProcessSampleSize (Vst::kSample64) == kResultTrue; + } + + //============================================================================== + + int getCurrentPreset() const noexcept override { return currentPreset; } + + void setCurrentPreset (int index) noexcept override + { + if (vst3Controller != nullptr) + { + // VST3 presets are loaded externally; track index only + currentPreset = index; + } + } + + int getNumPresets() const override { return numPresets; } + + String getPresetName (int index) const override + { + if (index < 0 || index >= numPresets) + return {}; + + return "Preset " + String (index); + } + + void setPresetName (int, StringRef) override {} + + //============================================================================== + + Result loadStateFromMemory (const MemoryBlock& memoryBlock) override + { + if (vst3Component == nullptr) + return Result::fail ("Plugin not loaded"); + + MemoryBlock mutable_copy = memoryBlock; + IBStream* stream = new MemoryStream (mutable_copy.getData(), + static_cast (mutable_copy.getSize())); + + const auto res = vst3Component->setState (stream); + stream->release(); + + if (res != kResultOk) + return Result::fail ("VST3 setState() failed"); + + return Result::ok(); + } + + Result saveStateIntoMemory (MemoryBlock& memoryBlock) override + { + if (vst3Component == nullptr) + return Result::fail ("Plugin not loaded"); + + MemoryStream* stream = new MemoryStream(); + const auto res = vst3Component->getState (stream); + + if (res != kResultOk) + { + stream->release(); + return Result::fail ("VST3 getState() failed"); + } + + memoryBlock.replaceAll (stream->getData(), static_cast (stream->getSize())); + stream->release(); + return Result::ok(); + } + + //============================================================================== + + bool hasEditor() const override + { + return VST3Editor::create (vst3Controller.get()) != nullptr; + } + + AudioProcessorEditor* createEditor() override + { + if (auto editor = VST3Editor::create (vst3Controller.get())) + return editor.release(); + + return nullptr; + } + + //============================================================================== + + static std::unique_ptr create (const AudioPluginDescription& desc, + const AudioPluginHostContext& context) + { + auto mod = VST3Module::load (File (desc.fileOrBundlePath)); + if (mod == nullptr) + return nullptr; + + IPluginFactory* rawFactory = mod->getFactory(); + if (rawFactory == nullptr) + return nullptr; + + IPtr factory (rawFactory); + + // Find the component class matching identifier + const int classCount = factory->countClasses(); + for (int i = 0; i < classCount; ++i) + { + PClassInfo classInfo; + if (factory->getClassInfo (i, &classInfo) != kResultOk) + continue; + + if (String (classInfo.category) != "Audio Module Class") + continue; + + if (desc.identifier.isNotEmpty() && ! classIDToString (classInfo.cid).equalsIgnoreCase (desc.identifier)) + continue; + + if (desc.identifier.isEmpty() && String (classInfo.name) != desc.name) + continue; + + Vst::IComponent* rawComponent = nullptr; + if (factory->createInstance (classInfo.cid, Vst::IComponent::iid, reinterpret_cast (&rawComponent)) != kResultOk) + continue; + + IPtr component = IPtr::adopt (rawComponent); + + auto host = IPtr::adopt (new HostApplication (context)); + if (component->initialize (host.get()) != kResultOk) + continue; + + Vst::IAudioProcessor* rawProcessor = nullptr; + if (component->queryInterface (Vst::IAudioProcessor::iid, + reinterpret_cast (&rawProcessor)) + != kResultOk) + continue; + IPtr processor = IPtr::adopt (rawProcessor); + + Vst::IEditController* rawController = nullptr; + component->queryInterface (Vst::IEditController::iid, + reinterpret_cast (&rawController)); + IPtr controller = IPtr::adopt (rawController); + + bool controllerInitialized = false; + if (controller == nullptr) + { + TUID controllerClassId {}; + if (component->getControllerClassId (controllerClassId) == kResultTrue) + { + if (factory->createInstance (controllerClassId, + Vst::IEditController::iid, + reinterpret_cast (&rawController)) + == kResultOk) + { + controller = IPtr::adopt (rawController); + + if (controller->initialize (host.get()) != kResultOk) + controller = nullptr; + else + controllerInitialized = true; + } + } + } + + auto componentHandler = IPtr::adopt (new HostComponentHandler()); + if (controller != nullptr) + controller->setComponentHandler (componentHandler.get()); + + return std::make_unique (desc, + context, + std::move (mod), + std::move (host), + std::move (componentHandler), + std::move (component), + std::move (processor), + std::move (controller), + controllerInitialized); + } + + return nullptr; + } + +private: + static AudioBusLayout buildBusLayout (Vst::IComponent* component) + { + std::vector inputs, outputs; + + const int numInputs = component->getBusCount (Vst::kAudio, Vst::kInput); + for (int i = 0; i < numInputs; ++i) + { + Vst::BusInfo info; + component->getBusInfo (Vst::kAudio, Vst::kInput, i, info); + inputs.emplace_back (toString (info.name), AudioBus::Type::Audio, AudioBus::Direction::Input, static_cast (info.channelCount)); + } + + const int numOutputs = component->getBusCount (Vst::kAudio, Vst::kOutput); + for (int i = 0; i < numOutputs; ++i) + { + Vst::BusInfo info; + component->getBusInfo (Vst::kAudio, Vst::kOutput, i, info); + outputs.emplace_back (toString (info.name), AudioBus::Type::Audio, AudioBus::Direction::Output, static_cast (info.channelCount)); + } + + const int numMidiInputs = component->getBusCount (Vst::kEvent, Vst::kInput); + for (int i = 0; i < numMidiInputs; ++i) + { + Vst::BusInfo info; + component->getBusInfo (Vst::kEvent, Vst::kInput, i, info); + inputs.emplace_back (toString (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Input, 1); + } + + const int numMidiOutputs = component->getBusCount (Vst::kEvent, Vst::kOutput); + for (int i = 0; i < numMidiOutputs; ++i) + { + Vst::BusInfo info; + component->getBusInfo (Vst::kEvent, Vst::kOutput, i, info); + outputs.emplace_back (toString (info.name), AudioBus::Type::MIDI, AudioBus::Direction::Output, 1); + } + + return AudioBusLayout (std::move (inputs), std::move (outputs)); + } + + void buildParameterList() + { + if (vst3Controller == nullptr) + return; + + const int count = vst3Controller->getParameterCount(); + numPresets = 0; + + for (int i = 0; i < count; ++i) + { + Vst::ParameterInfo info; + vst3Controller->getParameterInfo (i, info); + + if (info.flags & Vst::ParameterInfo::kIsProgramChange) + { + numPresets = static_cast (info.stepCount) + 1; + continue; + } + + auto param = AudioParameterBuilder() + .withID (String (static_cast (info.id))) + .withName (toString (info.title)) + .withRange (0.0f, 1.0f) + .withDefault (static_cast (info.defaultNormalizedValue)) + .build(); + + vst3ParameterIds.push_back (info.id); + addParameter (std::move (param)); + } + + inputParameterChanges.setMaxParameters (static_cast (vst3ParameterIds.size())); + } + + AudioPluginHostContext hostContext; + std::unique_ptr vst3Module; + IPtr vst3HostApplication; + IPtr vst3ComponentHandler; + IPtr vst3Component; + IPtr vst3Processor; + IPtr vst3Controller; + Vst::ProcessContext vst3ProcessContext {}; + Vst::ParameterChanges inputParameterChanges; + Vst::EventList inputEvents; + Vst::EventList outputEvents; + AudioBuffer doublePrecisionBuffer; + std::vector vst3ParameterIds; + int currentPreset = 0; + int numPresets = 0; + bool processingPrepared = false; + bool vst3ControllerInitialized = false; + bool vst3ComponentsConnected = false; + + bool connectComponentAndController() + { + if (vst3Component == nullptr || vst3Controller == nullptr) + return false; + + Vst::IConnectionPoint* rawComponentConnection = nullptr; + if (vst3Component->queryInterface (Vst::IConnectionPoint::iid, + reinterpret_cast (&rawComponentConnection)) + != kResultOk) + { + return false; + } + + Vst::IConnectionPoint* rawControllerConnection = nullptr; + if (vst3Controller->queryInterface (Vst::IConnectionPoint::iid, + reinterpret_cast (&rawControllerConnection)) + != kResultOk) + { + rawComponentConnection->release(); + return false; + } + + auto componentConnection = IPtr::adopt (rawComponentConnection); + auto controllerConnection = IPtr::adopt (rawControllerConnection); + + if (componentConnection->connect (controllerConnection.get()) != kResultTrue) + return false; + + if (controllerConnection->connect (componentConnection.get()) != kResultTrue) + { + componentConnection->disconnect (controllerConnection.get()); + return false; + } + + vst3ComponentsConnected = true; + return true; + } + + void disconnectComponentAndController() + { + if (! vst3ComponentsConnected || vst3Component == nullptr || vst3Controller == nullptr) + return; + + Vst::IConnectionPoint* rawComponentConnection = nullptr; + Vst::IConnectionPoint* rawControllerConnection = nullptr; + + if (vst3Component->queryInterface (Vst::IConnectionPoint::iid, + reinterpret_cast (&rawComponentConnection)) + != kResultOk) + { + return; + } + + if (vst3Controller->queryInterface (Vst::IConnectionPoint::iid, + reinterpret_cast (&rawControllerConnection)) + != kResultOk) + { + rawComponentConnection->release(); + return; + } + + auto componentConnection = IPtr::adopt (rawComponentConnection); + auto controllerConnection = IPtr::adopt (rawControllerConnection); + + componentConnection->disconnect (controllerConnection.get()); + controllerConnection->disconnect (componentConnection.get()); + vst3ComponentsConnected = false; + } + + void prepareProcessData (Vst::ProcessData& data, int numSamples, int32 symbolicSampleSize) + { + data.processMode = isNonRealtime() ? Vst::kOffline : Vst::kRealtime; + data.symbolicSampleSize = symbolicSampleSize; + data.numSamples = numSamples; + + inputParameterChanges.clearQueue(); + const auto params = getParameters(); + const auto numParams = yup::jmin (params.size(), vst3ParameterIds.size()); + + for (std::size_t i = 0; i < numParams; ++i) + { + int32 queueIndex = 0; + if (auto* queue = inputParameterChanges.addParameterData (vst3ParameterIds[i], queueIndex)) + { + int32 pointIndex = 0; + queue->addPoint (0, + static_cast (params[i]->getValue()), + pointIndex); + } + } + + data.inputParameterChanges = &inputParameterChanges; + data.inputEvents = &inputEvents; + data.outputEvents = &outputEvents; + + inputEvents.clear(); + outputEvents.clear(); + + if (hostContext.playHead == nullptr) + return; + + const auto optPos = hostContext.playHead->getPosition(); + if (! optPos.has_value()) + return; + + const auto& posInfo = optPos.value(); + vst3ProcessContext = {}; + vst3ProcessContext.state = Vst::ProcessContext::kPlaying; + vst3ProcessContext.sampleRate = getSampleRate(); + + if (auto timeSamples = posInfo.getTimeInSamples()) + vst3ProcessContext.projectTimeSamples = *timeSamples; + + if (auto tempo = posInfo.getBpm()) + vst3ProcessContext.tempo = *tempo; + + data.processContext = &vst3ProcessContext; + } + + void prepareMidiInputEvents (const MidiBuffer& midiBuffer) + { + int count = 0; + + for (const auto& metadata : midiBuffer) + { + ignoreUnused (metadata); + ++count; + } + + inputEvents.setMaxSize (jmax (64, count)); + inputEvents.clear(); + + for (const auto& metadata : midiBuffer) + addMidiMessageToVST3Events (inputEvents, metadata); + + outputEvents.setMaxSize (jmax (64, count * 2)); + outputEvents.clear(); + } + + void collectOutputEvents (MidiBuffer& midiBuffer) + { + midiBuffer.clear(); + + for (int32 i = 0; i < outputEvents.getEventCount(); ++i) + { + Vst::Event event {}; + if (outputEvents.getEvent (i, event) == kResultOk) + addVST3EventToMidiBuffer (event, midiBuffer); + } + } + + void nonRealtimeStateChanged() override + { + if (! processingPrepared || vst3Processor == nullptr || getSampleRate() <= 0.0f || getSamplesPerBlock() <= 0) + return; + + Vst::ProcessSetup setup; + setup.processMode = isNonRealtime() ? Vst::kOffline : Vst::kRealtime; + setup.symbolicSampleSize = isUsingDoublePrecision() ? Vst::kSample64 : Vst::kSample32; + setup.maxSamplesPerBlock = getSamplesPerBlock(); + setup.sampleRate = getSampleRate(); + vst3Processor->setupProcessing (setup); + } +}; + +//============================================================================== + +VST3Format::VST3Format() = default; +VST3Format::~VST3Format() = default; + +AudioPluginFormatType VST3Format::getFormatType() const +{ + return AudioPluginFormatType::vst3; +} + +String VST3Format::getFormatName() const +{ + return "VST3"; +} + +StringArray VST3Format::getFileExtensions() const +{ + return { ".vst3" }; +} + +FileSearchPath VST3Format::getDefaultSearchPaths() const +{ + FileSearchPath paths; + +#if YUP_MAC + paths.add (File ("/Library/Audio/Plug-Ins/VST3")); + paths.add (File::getSpecialLocation (File::userHomeDirectory) + .getChildFile ("Library/Audio/Plug-Ins/VST3")); +#elif YUP_WINDOWS + // %CommonProgramFiles%\VST3 + if (const char* pf = getenv ("CommonProgramFiles")) + paths.add (File (String (pf) + "\\VST3")); + // %APPDATA%\VST3 + if (const char* appdata = getenv ("APPDATA")) + paths.add (File (String (appdata) + "\\VST3")); +#elif YUP_LINUX + paths.add (File ("/usr/lib/vst3")); + paths.add (File ("/usr/local/lib/vst3")); + paths.add (File::getSpecialLocation (File::userHomeDirectory).getChildFile (".vst3")); +#endif + + return paths; +} + +ResultValue> VST3Format::scanFile (const File& file) +{ + if (file.getFileExtension().toLowerCase() != ".vst3" + && ! file.isDirectory()) + return makeResultValueFail ("Not a VST3 file"); + + auto mod = VST3Module::load (file); + if (mod == nullptr) + return makeResultValueFail ("Failed to load VST3 module: " + file.getFullPathName()); + + IPluginFactory* rawFactory = mod->getFactory(); + if (rawFactory == nullptr) + return makeResultValueFail ("No factory in " + file.getFullPathName()); + + IPtr factory (rawFactory); + + std::vector results; + const int classCount = factory->countClasses(); + + for (int i = 0; i < classCount; ++i) + { + PClassInfo2 info2 {}; + if (auto* factory2 = FUnknownPtr (factory).getInterface()) + { + if (factory2->getClassInfo2 (i, &info2) != kResultOk) + continue; + } + else + { + PClassInfo info; + if (factory->getClassInfo (i, &info) != kResultOk) + continue; + + std::memcpy (info2.cid, info.cid, sizeof (TUID)); + std::memcpy (info2.name, info.name, PClassInfo::kNameSize); + std::memcpy (info2.category, info.category, PClassInfo::kCategorySize); + info2.vendor[0] = '\0'; + info2.version[0] = '\0'; + info2.subCategories[0] = '\0'; + } + + if (String (info2.category) != "Audio Module Class") + continue; + + AudioPluginDescription desc; + desc.formatType = AudioPluginFormatType::vst3; + desc.fileOrBundlePath = file.getFullPathName(); + desc.name = String (info2.name); + desc.vendor = String (info2.vendor); + desc.version = String (info2.version); + desc.category = String (info2.subCategories); + desc.isInstrument = String (info2.subCategories).containsIgnoreCase ("Instrument"); + desc.isEffect = ! desc.isInstrument; + + desc.identifier = classIDToString (info2.cid); + + if (desc.isInstrument) + { + desc.numInputChannels = 0; + desc.numOutputChannels = 2; + desc.numMidiInputPorts = 1; + } + else + { + desc.numInputChannels = 2; + desc.numOutputChannels = 2; + } + + results.push_back (std::move (desc)); + } + + if (results.empty()) + return makeResultValueFail ("No Audio Module Class entries in " + file.getFullPathName()); + + return makeResultValueOk (std::move (results)); +} + +ResultValue> VST3Format::loadPlugin ( + const AudioPluginDescription& description, + const AudioPluginHostContext& context) +{ + auto instance = VST3Instance::create (description, context); + + if (instance == nullptr) + return makeResultValueFail ("Failed to load VST3 plugin: " + description.name); + + return makeResultValueOk (std::move (instance)); +} + +} // namespace yup + +#endif // YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3 diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.h b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.h new file mode 100644 index 000000000..c1613809e --- /dev/null +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.h @@ -0,0 +1,54 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3 + +namespace yup +{ + +/** + AudioPluginFormat implementation for VST3. + + Scans .vst3 bundles/folders by loading their module factory and enumerating + classes. Loads plugins via IComponent + IAudioProcessor setup. +*/ +class VST3Format : public AudioPluginFormat +{ +public: + VST3Format(); + ~VST3Format() override; + + AudioPluginFormatType getFormatType() const override; + String getFormatName() const override; + StringArray getFileExtensions() const override; + + FileSearchPath getDefaultSearchPaths() const override; + + ResultValue> scanFile (const File& file) override; + + ResultValue> loadPlugin ( + const AudioPluginDescription& description, + const AudioPluginHostContext& context) override; +}; + +} // namespace yup + +#endif // YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3 diff --git a/modules/yup_audio_plugin_host/yup_audio_plugin_host.cpp b/modules/yup_audio_plugin_host/yup_audio_plugin_host.cpp new file mode 100644 index 000000000..7c00b9eb8 --- /dev/null +++ b/modules/yup_audio_plugin_host/yup_audio_plugin_host.cpp @@ -0,0 +1,95 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_MAC +#define YUP_CORE_INCLUDE_OBJC_HELPERS 1 +#endif + +#include "yup_audio_plugin_host.h" + +#include +#include +#include +#include +#include +#include + +//============================================================================== +#include "host/yup_AudioPluginScanner.cpp" +#include "host/yup_AudioPluginInstance.cpp" + +//============================================================================== +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_VST3 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if YUP_MAC +#import +#include +#endif + +#include "native/yup_AudioPluginInstance_VST3.cpp" +#endif + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_CLAP +#include + +#if YUP_WINDOWS +#include +using CLAPModuleHandle = HMODULE; +#define clapLoadModule(p) LoadLibraryA (p) +#define clapGetAddress GetProcAddress +#define clapUnloadModule FreeLibrary +#else +#include +using CLAPModuleHandle = void*; +#define clapLoadModule(p) dlopen (p, RTLD_LAZY | RTLD_LOCAL) +#define clapGetAddress dlsym +#define clapUnloadModule dlclose +#endif + +#include "native/yup_AudioPluginInstance_CLAP.cpp" +#endif + +#if YUP_AUDIO_PLUGIN_HOST_ENABLE_AU && YUP_MAC +#import +#import +#import +#import +#import +#import +#import + +#include "native/yup_AudioPluginInstance_AUv2.mm" +#endif diff --git a/modules/yup_audio_plugin_host/yup_audio_plugin_host.h b/modules/yup_audio_plugin_host/yup_audio_plugin_host.h new file mode 100644 index 000000000..9826cf05f --- /dev/null +++ b/modules/yup_audio_plugin_host/yup_audio_plugin_host.h @@ -0,0 +1,59 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: yup_audio_plugin_host + vendor: yup + version: 1.0.0 + name: YUP Audio Plugin Host + description: In-process hosting of VST3, CLAP, and AUv2 audio plugins. + website: https://github.com/kunitoki/yup + license: ISC + + dependencies: yup_audio_processors + searchpaths: native + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once +#define YUP_AUDIO_PLUGIN_HOST_H_INCLUDED + +#include + +//============================================================================== +#include "host/yup_AudioPluginFormatType.h" +#include "host/yup_AudioPluginDescription.h" +#include "host/yup_AudioPluginHostContext.h" +#include "host/yup_AudioPluginFormat.h" +#include "host/yup_AudioPluginScanner.h" +#include "host/yup_AudioPluginInstance.h" + +//============================================================================== +#include "native/yup_AudioPluginInstance_VST3.h" +#include "native/yup_AudioPluginInstance_CLAP.h" +#include "native/yup_AudioPluginInstance_AUv2.h" diff --git a/modules/yup_audio_plugin_host/yup_audio_plugin_host.mm b/modules/yup_audio_plugin_host/yup_audio_plugin_host.mm new file mode 100644 index 000000000..7b8f41c3a --- /dev/null +++ b/modules/yup_audio_plugin_host/yup_audio_plugin_host.mm @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_audio_plugin_host.cpp" diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp index d72013c58..259e758ca 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp @@ -55,12 +55,24 @@ void AudioProcessor::addParameter (AudioParameter::Ptr parameter) int AudioProcessor::getNumAudioOutputs() const { - return static_cast (busLayout.getOutputBuses().size()); + int count = 0; + + for (const auto& bus : busLayout.getOutputBuses()) + if (bus.getType() == AudioBus::Type::Audio) + ++count; + + return count; } int AudioProcessor::getNumAudioInputs() const { - return static_cast (busLayout.getInputBuses().size()); + int count = 0; + + for (const auto& bus : busLayout.getInputBuses()) + if (bus.getType() == AudioBus::Type::Audio) + ++count; + + return count; } //============================================================================== @@ -86,13 +98,37 @@ bool AudioProcessor::isSuspended() const //============================================================================== +void AudioProcessor::setProcessingPrecision (ProcessingPrecision precision) +{ + if (precision == ProcessingPrecision::doublePrecision && ! supportsDoublePrecisionProcessing()) + { + jassertfalse; + processingPrecision = ProcessingPrecision::singlePrecision; + return; + } + + processingPrecision = precision; +} + +//============================================================================== + +void AudioProcessor::processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) +{ + ignoreUnused (audioBuffer, midiBuffer); +} + +void AudioProcessor::processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) +{ + ignoreUnused (audioBuffer, midiBuffer); +} + +//============================================================================== + void AudioProcessor::setPlaybackConfiguration (float sampleRate, int samplesPerBlock) { releaseResources(); - this->sampleRate = sampleRate; this->samplesPerBlock = samplesPerBlock; - prepareToPlay (sampleRate, samplesPerBlock); } diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.h b/modules/yup_audio_processors/processors/yup_AudioProcessor.h index 61018a8e1..d560a9903 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.h +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.h @@ -33,6 +33,14 @@ class AudioProcessorEditor; class YUP_API AudioProcessor { public: + //============================================================================== + /** The floating-point precision used for processBlock() calls. */ + enum class ProcessingPrecision + { + singlePrecision, + doublePrecision + }; + //============================================================================== /** Constructs an AudioProcessor. */ @@ -67,7 +75,11 @@ class YUP_API AudioProcessor //============================================================================== - /** Prepares the processor for playback. */ + /** Prepares the processor for playback. + + getSampleRate() and getSamplesPerBlock() are guaranteed to return the correct + values when this is called. Subclasses do not need to call the base class. + */ virtual void prepareToPlay (float sampleRate, int maxBlockSize) = 0; /** Releases resources. */ @@ -89,11 +101,45 @@ class YUP_API AudioProcessor */ virtual void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) {} + /** + Processes a block while the processor is bypassed. + + The default implementation leaves audio and MIDI unchanged. + + @param audioBuffer The audio buffer to process. + @param midiBuffer The MIDI buffer to process. + */ + virtual void processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer); + + /** + Processes a block while the processor is bypassed. + + The default implementation leaves audio and MIDI unchanged. + + @param audioBuffer The audio buffer to process. + @param midiBuffer The MIDI buffer to process. + */ + virtual void processBlockBypassed (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer); + /** Flushes the processor. */ virtual void flush() {} //============================================================================== + /** Returns true if this processor implements the double-precision processBlock(). */ + virtual bool supportsDoublePrecisionProcessing() const { return false; } + + /** Sets the preferred processing precision for future processBlock() calls. */ + void setProcessingPrecision (ProcessingPrecision precision); + + /** Returns the current processing precision. */ + ProcessingPrecision getProcessingPrecision() const noexcept { return processingPrecision; } + + /** Returns true when the current processing precision is double precision. */ + bool isUsingDoublePrecision() const noexcept { return processingPrecision == ProcessingPrecision::doublePrecision; } + + //============================================================================== + CriticalSection& getProcessLock() { return processLock; } bool isSuspended() const; @@ -186,6 +232,7 @@ class YUP_API AudioProcessor float sampleRate = 44100.0f; int samplesPerBlock = 1024; + ProcessingPrecision processingPrecision = ProcessingPrecision::singlePrecision; AudioPlayHead* playHead = nullptr; diff --git a/modules/yup_audio_processors/yup_audio_processors.cpp b/modules/yup_audio_processors/yup_audio_processors.cpp index d87eb5779..e6a82126e 100644 --- a/modules/yup_audio_processors/yup_audio_processors.cpp +++ b/modules/yup_audio_processors/yup_audio_processors.cpp @@ -1,37 +1,40 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2024 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#ifdef YUP_AUDIO_PROCESSORS_H_INCLUDED -/* When you add this cpp file to your project, you mustn't include it in a file where you've - already included any other headers - just put it inside a file on its own, possibly with your config - flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix - header files that the compiler may be using. -*/ -#error "Incorrect use of YUP cpp file" -#endif - -#include "yup_audio_processors.h" - -//============================================================================== -#include "processors/yup_AudioParameter.cpp" -#include "processors/yup_AudioParameterBuilder.cpp" -#include "processors/yup_AudioProcessor.cpp" -#include "processors/yup_AudioProcessorEditor.cpp" +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2024 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#ifdef YUP_AUDIO_PROCESSORS_H_INCLUDED +/* When you add this cpp file to your project, you mustn't include it in a file where you've + already included any other headers - just put it inside a file on its own, possibly with your config + flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix + header files that the compiler may be using. +*/ +#error "Incorrect use of YUP cpp file" +#endif + +#include "yup_audio_processors.h" + +//============================================================================== +#include "processors/yup_AudioParameter.cpp" +#include "processors/yup_AudioParameterBuilder.cpp" +#include "processors/yup_AudioProcessor.cpp" + +#if YUP_MODULE_AVAILABLE_yup_gui +#include "processors/yup_AudioProcessorEditor.cpp" +#endif diff --git a/modules/yup_audio_processors/yup_audio_processors.h b/modules/yup_audio_processors/yup_audio_processors.h index 638d22f9b..eca653ff6 100644 --- a/modules/yup_audio_processors/yup_audio_processors.h +++ b/modules/yup_audio_processors/yup_audio_processors.h @@ -1,55 +1,61 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2024 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -/* - ============================================================================== - - BEGIN_YUP_MODULE_DECLARATION - - ID: yup_audio_processors - vendor: yup - version: 1.0.0 - name: YUP Audio Processors - description: The essential set of basic YUP audio processing classes. - website: https://github.com/kunitoki/yup - license: ISC - - dependencies: yup_audio_basics yup_gui - - END_YUP_MODULE_DECLARATION - - ============================================================================== -*/ - -#pragma once -#define YUP_AUDIO_PROCESSORS_H_INCLUDED - -#include -#include - -//============================================================================== -#include "processors/yup_AudioBus.h" -#include "processors/yup_AudioBusLayout.h" -#include "processors/yup_AudioParameter.h" -#include "processors/yup_AudioParameterBuilder.h" -#include "processors/yup_AudioParameterHandle.h" -#include "processors/yup_AudioProcessor.h" -#include "processors/yup_AudioProcessorEditor.h" +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2024 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: yup_audio_processors + vendor: yup + version: 1.0.0 + name: YUP Audio Processors + description: The essential set of basic YUP audio processing classes. + website: https://github.com/kunitoki/yup + license: ISC + + dependencies: yup_audio_basics + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once +#define YUP_AUDIO_PROCESSORS_H_INCLUDED + +#include + +#if YUP_MODULE_AVAILABLE_yup_gui +#include +#endif + +//============================================================================== +#include "processors/yup_AudioBus.h" +#include "processors/yup_AudioBusLayout.h" +#include "processors/yup_AudioParameter.h" +#include "processors/yup_AudioParameterBuilder.h" +#include "processors/yup_AudioParameterHandle.h" +#include "processors/yup_AudioProcessor.h" + +#if YUP_MODULE_AVAILABLE_yup_gui +#include "processors/yup_AudioProcessorEditor.h" +#endif diff --git a/modules/yup_core/memory/yup_MemoryBlock.cpp b/modules/yup_core/memory/yup_MemoryBlock.cpp index 67cdcb2f2..a1957429c 100644 --- a/modules/yup_core/memory/yup_MemoryBlock.cpp +++ b/modules/yup_core/memory/yup_MemoryBlock.cpp @@ -1,518 +1,444 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2024 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== - - This file is part of the JUCE library. - Copyright (c) 2022 - Raw Material Software Limited - - JUCE is an open source library subject to commercial or open-source - licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - To use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -namespace yup -{ - -MemoryBlock::MemoryBlock() noexcept {} - -MemoryBlock::MemoryBlock (size_t initialSize, bool initialiseToZero) -{ - if (initialSize > 0) - { - size = initialSize; - data.allocate (initialSize, initialiseToZero); - } - else - { - size = 0; - } -} - -MemoryBlock::MemoryBlock (const MemoryBlock& other) - : size (other.size) -{ - if (size > 0) - { - jassert (other.data != nullptr); - data.malloc (size); - memcpy (data, other.data, size); - } -} - -MemoryBlock::MemoryBlock (const void* const dataToInitialiseFrom, const size_t sizeInBytes) - : size (sizeInBytes) -{ - jassert (((ssize_t) sizeInBytes) >= 0); - - if (size > 0) - { - jassert (dataToInitialiseFrom != nullptr); // non-zero size, but a zero pointer passed-in? - - data.malloc (size); - - if (dataToInitialiseFrom != nullptr) - memcpy (data, dataToInitialiseFrom, size); - } -} - -MemoryBlock::~MemoryBlock() noexcept -{ -} - -MemoryBlock& MemoryBlock::operator= (const MemoryBlock& other) -{ - if (this != &other) - { - setSize (other.size, false); - memcpy (data, other.data, size); - } - - return *this; -} - -MemoryBlock::MemoryBlock (MemoryBlock&& other) noexcept - : data (std::move (other.data)) - , size (other.size) -{ -} - -MemoryBlock& MemoryBlock::operator= (MemoryBlock&& other) noexcept -{ - data = std::move (other.data); - size = other.size; - return *this; -} - -//============================================================================== -bool MemoryBlock::operator== (const MemoryBlock& other) const noexcept -{ - return matches (other.data, other.size); -} - -bool MemoryBlock::operator!= (const MemoryBlock& other) const noexcept -{ - return ! operator== (other); -} - -bool MemoryBlock::matches (const void* dataToCompare, size_t dataSize) const noexcept -{ - return size == dataSize - && memcmp (data, dataToCompare, size) == 0; -} - -//============================================================================== -// this will resize the block to this size -void MemoryBlock::setSize (const size_t newSize, const bool initialiseToZero) -{ - if (size != newSize) - { - if (newSize <= 0) - { - reset(); - } - else - { - if (data != nullptr) - { - data.realloc (newSize); - - if (initialiseToZero && (newSize > size)) - zeromem (data + size, newSize - size); - } - else - { - data.allocate (newSize, initialiseToZero); - } - - size = newSize; - } - } -} - -void MemoryBlock::reset() -{ - data.free(); - size = 0; -} - -void MemoryBlock::ensureSize (size_t minimumSize, bool initialiseToZero) -{ - if (size < minimumSize) - setSize (minimumSize, initialiseToZero); -} - -void MemoryBlock::swapWith (MemoryBlock& other) noexcept -{ - std::swap (size, other.size); - data.swapWith (other.data); -} - -//============================================================================== -void MemoryBlock::fillWith (uint8 value) noexcept -{ - memset (data, (int) value, size); -} - -void MemoryBlock::append (const void* srcData, size_t numBytes) -{ - if (numBytes > 0) - { - jassert (srcData != nullptr); // this must not be null! - auto oldSize = size; - setSize (size + numBytes); - memcpy (data + oldSize, srcData, numBytes); - } -} - -void MemoryBlock::replaceAll (const void* srcData, size_t numBytes) -{ - if (numBytes <= 0) - { - reset(); - return; - } - - jassert (srcData != nullptr); // this must not be null! - setSize (numBytes); - memcpy (data, srcData, numBytes); -} - -void MemoryBlock::insert (const void* srcData, size_t numBytes, size_t insertPosition) -{ - if (numBytes > 0) - { - jassert (srcData != nullptr); // this must not be null! - insertPosition = jmin (size, insertPosition); - auto trailingDataSize = size - insertPosition; - setSize (size + numBytes, false); - - if (trailingDataSize > 0) - memmove (data + insertPosition + numBytes, - data + insertPosition, - trailingDataSize); - - memcpy (data + insertPosition, srcData, numBytes); - } -} - -void MemoryBlock::removeSection (size_t startByte, size_t numBytesToRemove) -{ - if (startByte + numBytesToRemove >= size) - { - setSize (startByte); - } - else if (numBytesToRemove > 0) - { - memmove (data + startByte, - data + startByte + numBytesToRemove, - size - (startByte + numBytesToRemove)); - - setSize (size - numBytesToRemove); - } -} - -void MemoryBlock::copyFrom (const void* const src, int offset, size_t num) noexcept -{ - auto* d = static_cast (src); - - if (offset < 0) - { - d -= offset; - num += (size_t) -offset; - offset = 0; - } - - if ((size_t) offset + num > size) - num = size - (size_t) offset; - - if (num > 0) - memcpy (data + offset, d, num); -} - -void MemoryBlock::copyTo (void* const dst, int offset, size_t num) const noexcept -{ - auto* d = static_cast (dst); - - if (offset < 0) - { - zeromem (d, (size_t) -offset); - d -= offset; - num -= (size_t) -offset; - offset = 0; - } - - if ((size_t) offset + num > size) - { - auto newNum = (size_t) size - (size_t) offset; - zeromem (d + newNum, num - newNum); - num = newNum; - } - - if (num > 0) - memcpy (d, data + offset, num); -} - -String MemoryBlock::toString() const -{ - return String::fromUTF8 (data, (int) size); -} - -//============================================================================== -int MemoryBlock::getBitRange (size_t bitRangeStart, size_t numBits) const noexcept -{ - int res = 0; - - auto byte = bitRangeStart >> 3; - auto offsetInByte = bitRangeStart & 7; - size_t bitsSoFar = 0; - - while (numBits > 0 && (size_t) byte < size) - { - auto bitsThisTime = jmin (numBits, 8 - offsetInByte); - const int mask = (0xff >> (8 - bitsThisTime)) << offsetInByte; - - res |= (((data[byte] & mask) >> offsetInByte) << bitsSoFar); - - bitsSoFar += bitsThisTime; - numBits -= bitsThisTime; - ++byte; - offsetInByte = 0; - } - - return res; -} - -void MemoryBlock::setBitRange (const size_t bitRangeStart, size_t numBits, int bitsToSet) noexcept -{ - auto byte = bitRangeStart >> 3; - auto offsetInByte = bitRangeStart & 7; - uint32 mask = ~((((uint32) 0xffffffff) << (32 - numBits)) >> (32 - numBits)); - - while (numBits > 0 && (size_t) byte < size) - { - auto bitsThisTime = jmin (numBits, 8 - offsetInByte); - - const uint32 tempMask = (mask << offsetInByte) | ~((((uint32) 0xffffffff) >> offsetInByte) << offsetInByte); - const uint32 tempBits = (uint32) bitsToSet << offsetInByte; - - data[byte] = (char) (((uint32) data[byte] & tempMask) | tempBits); - - ++byte; - numBits -= bitsThisTime; - bitsToSet >>= bitsThisTime; - mask >>= bitsThisTime; - offsetInByte = 0; - } -} - -//============================================================================== -void MemoryBlock::loadFromHexString (StringRef hex) -{ - ensureSize ((size_t) hex.length() >> 1); - char* dest = data; - auto t = hex.text; - - for (;;) - { - yup_wchar byte = 0; - - for (int loop = 2; --loop >= 0;) - { - byte <<= 4; - - for (;;) - { - auto c = t.getAndAdvance(); - - if (c >= '0' && c <= '9') - { - byte |= c - '0'; - break; - } - if (c >= 'a' && c <= 'z') - { - byte |= c - ('a' - 10); - break; - } - if (c >= 'A' && c <= 'Z') - { - byte |= c - ('A' - 10); - break; - } - - if (c == 0) - { - setSize (static_cast (dest - data)); - return; - } - } - } - - *dest++ = (char) byte; - } -} - -//============================================================================== -static const char base64EncodingTable[] = ".ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+"; - -String MemoryBlock::toBase64Encoding() const -{ - auto numChars = ((size << 3) + 5) / 6; - - String destString ((unsigned int) size); // store the length, followed by a '.', and then the data. - auto initialLen = destString.length(); - destString.preallocateBytes ((size_t) initialLen * sizeof (String::CharPointerType::CharType) + 2 + numChars); - - auto d = destString.getCharPointer(); - d += initialLen; - d.write ('.'); - - for (size_t i = 0; i < numChars; ++i) - d.write ((yup_wchar) (uint8) base64EncodingTable[getBitRange (i * 6, 6)]); - - d.writeNull(); - return destString; -} - -static const char base64DecodingTable[] = { - 63, - 0, - 0, - 0, - 0, - 53, - 54, - 55, - 56, - 57, - 58, - 59, - 60, - 61, - 62, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23, - 24, - 25, - 26, - 0, - 0, - 0, - 0, - 0, - 0, - 27, - 28, - 29, - 30, - 31, - 32, - 33, - 34, - 35, - 36, - 37, - 38, - 39, - 40, - 41, - 42, - 43, - 44, - 45, - 46, - 47, - 48, - 49, - 50, - 51, - 52 -}; - -bool MemoryBlock::fromBase64Encoding (StringRef s) -{ - auto dot = CharacterFunctions::find (s.text, (yup_wchar) '.'); - - if (dot.isEmpty()) - return false; - - auto numBytesNeeded = String (s.text, dot).getIntValue(); - - setSize ((size_t) numBytesNeeded, true); - - auto srcChars = dot + 1; - int pos = 0; - - for (;;) - { - auto c = (int) srcChars.getAndAdvance(); - - if (c == 0) - return true; - - c -= 43; - - if (isPositiveAndBelow (c, numElementsInArray (base64DecodingTable))) - { - setBitRange ((size_t) pos, 6, base64DecodingTable[c]); - pos += 6; - } - } -} - -} // namespace yup +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2024 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2022 - Raw Material Software Limited + + JUCE is an open source library subject to commercial or open-source + licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + To use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +MemoryBlock::MemoryBlock() noexcept {} + +MemoryBlock::MemoryBlock (size_t initialSize, bool initialiseToZero) +{ + if (initialSize > 0) + { + size = initialSize; + data.allocate (initialSize, initialiseToZero); + } + else + { + size = 0; + } +} + +MemoryBlock::MemoryBlock (const MemoryBlock& other) + : size (other.size) +{ + if (size > 0) + { + jassert (other.data != nullptr); + data.malloc (size); + memcpy (data, other.data, size); + } +} + +MemoryBlock::MemoryBlock (const void* const dataToInitialiseFrom, const size_t sizeInBytes) + : size (sizeInBytes) +{ + jassert (((ssize_t) sizeInBytes) >= 0); + + if (size > 0) + { + jassert (dataToInitialiseFrom != nullptr); // non-zero size, but a zero pointer passed-in? + + data.malloc (size); + + if (dataToInitialiseFrom != nullptr) + memcpy (data, dataToInitialiseFrom, size); + } +} + +MemoryBlock::~MemoryBlock() noexcept +{ +} + +MemoryBlock& MemoryBlock::operator= (const MemoryBlock& other) +{ + if (this != &other) + { + setSize (other.size, false); + memcpy (data, other.data, size); + } + + return *this; +} + +MemoryBlock::MemoryBlock (MemoryBlock&& other) noexcept + : data (std::move (other.data)) + , size (other.size) +{ +} + +MemoryBlock& MemoryBlock::operator= (MemoryBlock&& other) noexcept +{ + data = std::move (other.data); + size = other.size; + return *this; +} + +//============================================================================== +bool MemoryBlock::operator== (const MemoryBlock& other) const noexcept +{ + return matches (other.data, other.size); +} + +bool MemoryBlock::operator!= (const MemoryBlock& other) const noexcept +{ + return ! operator== (other); +} + +bool MemoryBlock::matches (const void* dataToCompare, size_t dataSize) const noexcept +{ + return size == dataSize + && memcmp (data, dataToCompare, size) == 0; +} + +//============================================================================== +// this will resize the block to this size +void MemoryBlock::setSize (const size_t newSize, const bool initialiseToZero) +{ + if (size != newSize) + { + if (newSize <= 0) + { + reset(); + } + else + { + if (data != nullptr) + { + data.realloc (newSize); + + if (initialiseToZero && (newSize > size)) + zeromem (data + size, newSize - size); + } + else + { + data.allocate (newSize, initialiseToZero); + } + + size = newSize; + } + } +} + +void MemoryBlock::reset() +{ + data.free(); + size = 0; +} + +void MemoryBlock::ensureSize (size_t minimumSize, bool initialiseToZero) +{ + if (size < minimumSize) + setSize (minimumSize, initialiseToZero); +} + +void MemoryBlock::swapWith (MemoryBlock& other) noexcept +{ + std::swap (size, other.size); + data.swapWith (other.data); +} + +//============================================================================== +void MemoryBlock::fillWith (uint8 value) noexcept +{ + memset (data, (int) value, size); +} + +void MemoryBlock::append (const void* srcData, size_t numBytes) +{ + if (numBytes > 0) + { + jassert (srcData != nullptr); // this must not be null! + auto oldSize = size; + setSize (size + numBytes); + memcpy (data + oldSize, srcData, numBytes); + } +} + +void MemoryBlock::replaceAll (const void* srcData, size_t numBytes) +{ + if (numBytes <= 0) + { + reset(); + return; + } + + jassert (srcData != nullptr); // this must not be null! + setSize (numBytes); + memcpy (data, srcData, numBytes); +} + +void MemoryBlock::insert (const void* srcData, size_t numBytes, size_t insertPosition) +{ + if (numBytes > 0) + { + jassert (srcData != nullptr); // this must not be null! + insertPosition = jmin (size, insertPosition); + auto trailingDataSize = size - insertPosition; + setSize (size + numBytes, false); + + if (trailingDataSize > 0) + memmove (data + insertPosition + numBytes, + data + insertPosition, + trailingDataSize); + + memcpy (data + insertPosition, srcData, numBytes); + } +} + +void MemoryBlock::removeSection (size_t startByte, size_t numBytesToRemove) +{ + if (startByte + numBytesToRemove >= size) + { + setSize (startByte); + } + else if (numBytesToRemove > 0) + { + memmove (data + startByte, + data + startByte + numBytesToRemove, + size - (startByte + numBytesToRemove)); + + setSize (size - numBytesToRemove); + } +} + +void MemoryBlock::copyFrom (const void* const src, int offset, size_t num) noexcept +{ + auto* d = static_cast (src); + + if (offset < 0) + { + d -= offset; + num += (size_t) -offset; + offset = 0; + } + + if ((size_t) offset + num > size) + num = size - (size_t) offset; + + if (num > 0) + memcpy (data + offset, d, num); +} + +void MemoryBlock::copyTo (void* const dst, int offset, size_t num) const noexcept +{ + auto* d = static_cast (dst); + + if (offset < 0) + { + zeromem (d, (size_t) -offset); + d -= offset; + num -= (size_t) -offset; + offset = 0; + } + + if ((size_t) offset + num > size) + { + auto newNum = (size_t) size - (size_t) offset; + zeromem (d + newNum, num - newNum); + num = newNum; + } + + if (num > 0) + memcpy (d, data + offset, num); +} + +String MemoryBlock::toString() const +{ + return String::fromUTF8 (data, (int) size); +} + +//============================================================================== +int MemoryBlock::getBitRange (size_t bitRangeStart, size_t numBits) const noexcept +{ + int res = 0; + + auto byte = bitRangeStart >> 3; + auto offsetInByte = bitRangeStart & 7; + size_t bitsSoFar = 0; + + while (numBits > 0 && (size_t) byte < size) + { + auto bitsThisTime = jmin (numBits, 8 - offsetInByte); + const int mask = (0xff >> (8 - bitsThisTime)) << offsetInByte; + + res |= (((data[byte] & mask) >> offsetInByte) << bitsSoFar); + + bitsSoFar += bitsThisTime; + numBits -= bitsThisTime; + ++byte; + offsetInByte = 0; + } + + return res; +} + +void MemoryBlock::setBitRange (const size_t bitRangeStart, size_t numBits, int bitsToSet) noexcept +{ + auto byte = bitRangeStart >> 3; + auto offsetInByte = bitRangeStart & 7; + uint32 mask = ~((((uint32) 0xffffffff) << (32 - numBits)) >> (32 - numBits)); + + while (numBits > 0 && (size_t) byte < size) + { + auto bitsThisTime = jmin (numBits, 8 - offsetInByte); + + const uint32 tempMask = (mask << offsetInByte) | ~((((uint32) 0xffffffff) >> offsetInByte) << offsetInByte); + const uint32 tempBits = (uint32) bitsToSet << offsetInByte; + + data[byte] = (char) (((uint32) data[byte] & tempMask) | tempBits); + + ++byte; + numBits -= bitsThisTime; + bitsToSet >>= bitsThisTime; + mask >>= bitsThisTime; + offsetInByte = 0; + } +} + +//============================================================================== +void MemoryBlock::loadFromHexString (StringRef hex) +{ + ensureSize ((size_t) hex.length() >> 1); + char* dest = data; + auto t = hex.text; + + for (;;) + { + yup_wchar byte = 0; + + for (int loop = 2; --loop >= 0;) + { + byte <<= 4; + + for (;;) + { + auto c = t.getAndAdvance(); + + if (c >= '0' && c <= '9') + { + byte |= c - '0'; + break; + } + if (c >= 'a' && c <= 'z') + { + byte |= c - ('a' - 10); + break; + } + if (c >= 'A' && c <= 'Z') + { + byte |= c - ('A' - 10); + break; + } + + if (c == 0) + { + setSize (static_cast (dest - data)); + return; + } + } + } + + *dest++ = (char) byte; + } +} + +//============================================================================== +static const char base64EncodingTable[] = ".ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+"; + +String MemoryBlock::toBase64Encoding() const +{ + auto numChars = ((size << 3) + 5) / 6; + + String destString ((unsigned int) size); // store the length, followed by a '.', and then the data. + auto initialLen = destString.length(); + destString.preallocateBytes ((size_t) initialLen * sizeof (String::CharPointerType::CharType) + 2 + numChars); + + auto d = destString.getCharPointer(); + d += initialLen; + d.write ('.'); + + for (size_t i = 0; i < numChars; ++i) + d.write ((yup_wchar) (uint8) base64EncodingTable[getBitRange (i * 6, 6)]); + + d.writeNull(); + return destString; +} + +// clang-format off +static const char base64DecodingTable[] = { + 63, 0, 0, 0, 0, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, + 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 0, 0, 0, 0, 0, 0, + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, + 51, 52 +}; +// clang-format on + +bool MemoryBlock::fromBase64Encoding (StringRef s) +{ + auto dot = CharacterFunctions::find (s.text, (yup_wchar) '.'); + + if (dot.isEmpty()) + return false; + + auto numBytesNeeded = String (s.text, dot).getIntValue(); + + setSize ((size_t) numBytesNeeded, true); + + auto srcChars = dot + 1; + std::size_t pos = 0; + + for (;;) + { + auto c = (int) srcChars.getAndAdvance(); + + if (c == 0) + return true; + + c -= 43; + + if (isPositiveAndBelow (c, numElementsInArray (base64DecodingTable))) + { + setBitRange ((size_t) pos, 6, base64DecodingTable[c]); + pos += 6; + } + } +} + +} // namespace yup diff --git a/modules/yup_core/misc/yup_ResultValue.h b/modules/yup_core/misc/yup_ResultValue.h index c94c615a0..da1968e79 100644 --- a/modules/yup_core/misc/yup_ResultValue.h +++ b/modules/yup_core/misc/yup_ResultValue.h @@ -1,187 +1,282 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2024 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -namespace yup -{ - -//============================================================================== -/** - Represents the 'success' or 'failure' of an operation that returns a value, and holds an associated - error message to describe the error when there's a failure. - - E.g. - @code - ResultValue myOperation() - { - if (doSomeKindOfFoobar()) - return ResultValue::ok (1337); - else - return ResultValue::fail ("foobar didn't work!"); - } - - const ResultValue result (myOperation()); - - if (result.wasOk()) - { - const int& resultInteger = result.getReference(); - - ...it's all good, use the value... - } - else - { - warnUserAboutFailure ("The foobar operation failed! Error message was: " - + result.getErrorMessage()); - } - @endcode - - @tags{Core} -*/ -template -class YUP_API ResultValue -{ -public: - //============================================================================== - /** Creates and returns a 'successful' result value. */ - template - static auto ok (U&& value) noexcept - -> std::enable_if_t, ResultValue> - { - return ResultValue (std::forward (value)); - } - - /** Creates a 'failure' result. - If you pass a blank error message in here, a default "Unknown Error" message will be used instead. - */ - static ResultValue fail (StringRef errorMessage) noexcept - { - return ResultValue (errorMessage.isEmpty() ? StringRef ("Unknown Error") : errorMessage, ErrorTag {}); - } - - //============================================================================== - /** Returns true if this result indicates a success. */ - bool wasOk() const noexcept - { - return valueOrErrorMessage.index() == 1; - } - - /** Returns true if this result indicates a failure. - You can use getErrorMessage() to retrieve the error message associated - with the failure. - */ - bool failed() const noexcept - { - return valueOrErrorMessage.index() != 1; - } - - /** Returns true if this result indicates a success. - This is equivalent to calling wasOk(). - */ - explicit operator bool() const noexcept - { - return valueOrErrorMessage.index() == 1; - } - - /** Returns true if this result indicates a failure. - This is equivalent to calling failed(). - */ - bool operator!() const noexcept - { - return valueOrErrorMessage.index() != 1; - } - - /** Returns a copy of the value that was set when this result was created. */ - T getValue() const - { - jassert (valueOrErrorMessage.index() == 1); // Trying to access the value of the result, when the result is holding an error instead! - - return std::get<1> (valueOrErrorMessage); - } - - /** Returns the mutable reference that was set when this result was created. */ - T& getReference() noexcept - { - jassert (valueOrErrorMessage.index() == 1); // Trying to access the value of the result, when the result is holding an error instead! - - return std::get<1> (valueOrErrorMessage); - } - - /** Returns the const reference that was set when this result was created. */ - const T& getReference() const noexcept - { - jassert (valueOrErrorMessage.index() == 1); // Trying to access the value of the result, when the result is holding an error instead! - - return std::get<1> (valueOrErrorMessage); - } - - /** Returns the error message that was set when this result was created. - For a successful result, this will be an empty string; - */ - const String& getErrorMessage() const noexcept - { - jassert (valueOrErrorMessage.index() == 2); // Trying to access the error message of the result, when the result is holding a value instead! - - return std::get<2> (valueOrErrorMessage); - } - - //============================================================================== - ResultValue (const ResultValue&) = default; - ResultValue& operator= (const ResultValue&) = default; - ResultValue (ResultValue&&) noexcept = default; - ResultValue& operator= (ResultValue&&) noexcept = default; - - bool operator== (const ResultValue& other) const noexcept - { - return valueOrErrorMessage == other.valueOrErrorMessage; - } - - bool operator!= (const ResultValue& other) const noexcept - { - return valueOrErrorMessage != other.valueOrErrorMessage; - } - -private: - std::variant valueOrErrorMessage; - - struct ErrorTag - { - }; - - // The default constructor is not for public use! - // Instead, use ResultValue::ok() or ResultValue::fail() - ResultValue() noexcept {} - - template - explicit ResultValue (U&& value) noexcept - : valueOrErrorMessage (std::in_place_index<1>, std::forward (value)) - { - } - - ResultValue (StringRef message, ErrorTag) noexcept - : valueOrErrorMessage (std::in_place_index<2>, message) - { - } - - // These casts are private to prevent people trying to use the ResultValue object in numeric contexts - operator int() const; - operator void*() const; -}; - -} // namespace yup +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2024 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Helper type returned by yup::makeResultValueOk(), implicitly convertible to a successful ResultValue. + + @see makeResultValueOk, ResultValue + @tags{Core} +*/ +template +struct YUP_API OkValue +{ + template , int> = 0> + explicit constexpr OkValue (U&& v) noexcept (std::is_nothrow_constructible_v) + : value (std::forward (v)) + { + } + + T value; +}; + +/** Helper type returned by yup::makeResultValueFail(), implicitly convertible to a failed ResultValue. + + @see makeResultValueFail, ResultValue + @tags{Core} +*/ +struct YUP_API FailValue +{ + explicit FailValue (StringRef errorMessage) noexcept + : message (errorMessage.isEmpty() ? StringRef ("Unknown Error") : errorMessage) + { + } + + String message; +}; + +//============================================================================== +/** Creates an OkValue that implicitly converts to a successful ResultValue, allowing + return-site type deduction without naming ResultValue explicitly. + + @code + ResultValue myOperation() + { + return yup::makeResultValueOk (1337); // T is deduced from the argument + } + @endcode + + @see ResultValue, makeResultValueFail + @tags{Core} +*/ +template +[[nodiscard]] constexpr auto makeResultValueOk (T&& value) noexcept (std::is_nothrow_constructible_v, T>) + -> OkValue> +{ + return OkValue> (std::forward (value)); +} + +/** Creates a FailValue that implicitly converts to a failed ResultValue, allowing + return-site failure without naming ResultValue explicitly. + + @code + ResultValue myOperation() + { + return yup::makeResultValueFail ("something went wrong"); // Works for any ResultValue + } + @endcode + + @see ResultValue, makeResultValueOk + @tags{Core} +*/ +[[nodiscard]] inline FailValue makeResultValueFail (StringRef errorMessage) noexcept +{ + return FailValue (errorMessage); +} + +//============================================================================== +/** + Represents the 'success' or 'failure' of an operation that returns a value, and holds an associated + error message to describe the error when there's a failure. + + Prefer the free functions yup::makeResultValueOk (..) and yup::makeResultValueFail() over the static methods to avoid + having to repeat the template type at the call site: + + @code + ResultValue myOperation() + { + if (doSomeKindOfFoobar()) + return yup::makeResultValueOk (1337); + else + return yup::makeResultValueFail ("foobar didn't work!"); + } + + const ResultValue result (myOperation()); + + if (result.wasOk()) + { + const int& resultInteger = result.getReference(); + + ...it's all good, use the value... + } + else + { + warnUserAboutFailure ("The foobar operation failed! Error message was: " + + result.getErrorMessage()); + } + @endcode + + @tags{Core} +*/ +template +class YUP_API ResultValue +{ +public: + //============================================================================== + /** Creates and returns a 'successful' result value. */ + template + static auto ok (U&& value) noexcept + -> std::enable_if_t, ResultValue> + { + return ResultValue (std::forward (value)); + } + + /** Creates a 'failure' result. + If you pass a blank error message in here, a default "Unknown Error" message will be used instead. + */ + static ResultValue fail (StringRef errorMessage) noexcept + { + return ResultValue (errorMessage.isEmpty() ? StringRef ("Unknown Error") : errorMessage, ErrorTag {}); + } + + //============================================================================== + /** Returns true if this result indicates a success. */ + bool wasOk() const noexcept + { + return valueOrErrorMessage.index() == 1; + } + + /** Returns true if this result indicates a failure. + You can use getErrorMessage() to retrieve the error message associated + with the failure. + */ + bool failed() const noexcept + { + return valueOrErrorMessage.index() != 1; + } + + /** Returns true if this result indicates a success. + This is equivalent to calling wasOk(). + */ + explicit operator bool() const noexcept + { + return valueOrErrorMessage.index() == 1; + } + + /** Returns true if this result indicates a failure. + This is equivalent to calling failed(). + */ + bool operator!() const noexcept + { + return valueOrErrorMessage.index() != 1; + } + + /** Returns a copy of the value that was set when this result was created. */ + T getValue() const& + { + jassert (valueOrErrorMessage.index() == 1); // Trying to access the value of the result, when the result is holding an error instead! + + return std::get<1> (valueOrErrorMessage); + } + + /** Returns a moved from value that was set when this result was created. */ + T getValue() && + { + jassert (valueOrErrorMessage.index() == 1); // Trying to access the value of the result, when the result is holding an error instead! + + return std::get<1> (std::move (valueOrErrorMessage)); + } + + /** Returns the mutable reference that was set when this result was created. */ + T& getReference() noexcept + { + jassert (valueOrErrorMessage.index() == 1); // Trying to access the value of the result, when the result is holding an error instead! + + return std::get<1> (valueOrErrorMessage); + } + + /** Returns the const reference that was set when this result was created. */ + const T& getReference() const noexcept + { + jassert (valueOrErrorMessage.index() == 1); // Trying to access the value of the result, when the result is holding an error instead! + + return std::get<1> (valueOrErrorMessage); + } + + /** Returns the error message that was set when this result was created. + For a successful result, this will be an empty string; + */ + const String& getErrorMessage() const noexcept + { + jassert (valueOrErrorMessage.index() == 2); // Trying to access the error message of the result, when the result is holding a value instead! + + return std::get<2> (valueOrErrorMessage); + } + + //============================================================================== + ResultValue (const ResultValue&) = default; + ResultValue& operator= (const ResultValue&) = default; + ResultValue (ResultValue&&) noexcept = default; + ResultValue& operator= (ResultValue&&) noexcept = default; + + /** Constructs a successful result from an OkValue, enabling type-deduced return syntax. */ + template , int> = 0> + ResultValue (OkValue&& okVal) noexcept (std::is_nothrow_constructible_v) + : valueOrErrorMessage (std::in_place_index<1>, std::move (okVal.value)) + { + } + + /** Constructs a failed result from a FailValue, enabling type-deduced return syntax. */ + ResultValue (FailValue&& failVal) noexcept + : valueOrErrorMessage (std::in_place_index<2>, std::move (failVal.message)) + { + } + + bool operator== (const ResultValue& other) const noexcept + { + return valueOrErrorMessage == other.valueOrErrorMessage; + } + + bool operator!= (const ResultValue& other) const noexcept + { + return valueOrErrorMessage != other.valueOrErrorMessage; + } + +private: + std::variant valueOrErrorMessage; + + struct ErrorTag + { + }; + + // The default constructor is not for public use! + // Instead, use ResultValue::ok() or ResultValue::fail() + ResultValue() noexcept {} + + template + explicit ResultValue (U&& value) noexcept + : valueOrErrorMessage (std::in_place_index<1>, std::forward (value)) + { + } + + ResultValue (StringRef message, ErrorTag) noexcept + : valueOrErrorMessage (std::in_place_index<2>, message) + { + } + + // These casts are private to prevent people trying to use the ResultValue object in numeric contexts + operator int() const; + operator void*() const; +}; + +} // namespace yup diff --git a/modules/yup_core/system/yup_CompilerSupport.h b/modules/yup_core/system/yup_CompilerSupport.h index 2b4dbe442..03ce95ee4 100644 --- a/modules/yup_core/system/yup_CompilerSupport.h +++ b/modules/yup_core/system/yup_CompilerSupport.h @@ -1,131 +1,118 @@ -/* - ============================================================================== - - This file is part of the YUP library. - Copyright (c) 2024 - kunitoki@gmail.com - - YUP is an open source library subject to open-source licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - to use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== - - This file is part of the JUCE library. - Copyright (c) 2022 - Raw Material Software Limited - - JUCE is an open source library subject to commercial or open-source - licensing. - - The code included in this file is provided under the terms of the ISC license - http://www.isc.org/downloads/software-support-policy/isc-license. Permission - To use, copy, modify, and/or distribute this software for any purpose with or - without fee is hereby granted provided that the above copyright notice and - this permission notice appear in all copies. - - JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER - EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE - DISCLAIMED. - - ============================================================================== -*/ - -#pragma once - -/* - This file provides flags for compiler features that aren't supported on all platforms. -*/ - -//============================================================================== -// GCC -#if YUP_GCC - -#if (__GNUC__ * 100 + __GNUC_MINOR__) < 700 -#error "YUP requires GCC 7.0 or later" -#endif - -#ifndef YUP_EXCEPTIONS_DISABLED -#if ! __EXCEPTIONS -#define YUP_EXCEPTIONS_DISABLED 1 -#endif -#endif - -#define YUP_CXX17_IS_AVAILABLE (__cplusplus >= 201703L) -#define YUP_CXX20_IS_AVAILABLE (__cplusplus >= 202002L) - -#endif - -//============================================================================== -// Clang -#if YUP_CLANG - -#if (__clang_major__ < 6) -#error "YUP requires Clang 6 or later" -#endif - -#ifndef YUP_COMPILER_SUPPORTS_ARC -#define YUP_COMPILER_SUPPORTS_ARC 1 -#endif - -#ifndef YUP_EXCEPTIONS_DISABLED -#if ! __has_feature(cxx_exceptions) -#define YUP_EXCEPTIONS_DISABLED 1 -#endif -#endif - -#if ! defined(YUP_SILENCE_XCODE_15_LINKER_WARNING) \ - && defined(__apple_build_version__) \ - && __apple_build_version__ >= 15000000 \ - && __apple_build_version__ < 15000100 - - // Due to known issues, the linker in Xcode 15.0 may produce broken binaries. -#error Please upgrade to Xcode 15.1 or higher -#endif - -#define YUP_CXX17_IS_AVAILABLE (__cplusplus >= 201703L) -#define YUP_CXX20_IS_AVAILABLE (__cplusplus >= 202002L) - -#endif - -//============================================================================== -// MSVC -#if YUP_MSVC - -#if _MSC_FULL_VER < 191025017 // VS2017 -#error "YUP requires Visual Studio 2017 or later" -#endif - -#ifndef YUP_EXCEPTIONS_DISABLED -#if ! _CPPUNWIND -#define YUP_EXCEPTIONS_DISABLED 1 -#endif -#endif - -#define YUP_CXX17_IS_AVAILABLE (_MSVC_LANG >= 201703L) -#define YUP_CXX20_IS_AVAILABLE (_MSVC_LANG >= 202002L) -#endif - -//============================================================================== -#if ! YUP_CXX17_IS_AVAILABLE -#error "YUP requires C++17 or later" -#endif - -//============================================================================== -#ifndef DOXYGEN -// These are old flags that are now supported on all compatible build targets -#define YUP_CXX14_IS_AVAILABLE 1 -#define YUP_COMPILER_SUPPORTS_OVERRIDE_AND_FINAL 1 -#define YUP_COMPILER_SUPPORTS_VARIADIC_TEMPLATES 1 -#define YUP_COMPILER_SUPPORTS_INITIALIZER_LISTS 1 -#define YUP_COMPILER_SUPPORTS_NOEXCEPT 1 -#define YUP_DELETED_FUNCTION = delete -#define YUP_CONSTEXPR constexpr -#define YUP_NODISCARD [[nodiscard]] -#endif +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2024 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2022 - Raw Material Software Limited + + JUCE is an open source library subject to commercial or open-source + licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + To use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +/* + This file provides flags for compiler features that aren't supported on all platforms. +*/ + +//============================================================================== +// GCC +#if YUP_GCC + +#if (__GNUC__ * 100 + __GNUC_MINOR__) < 700 +#error "YUP requires GCC 7.0 or later" +#endif + +#ifndef YUP_EXCEPTIONS_DISABLED +#if ! __EXCEPTIONS +#define YUP_EXCEPTIONS_DISABLED 1 +#endif +#endif + +#define YUP_CXX20_IS_AVAILABLE (__cplusplus >= 202002L) +#define YUP_CXX23_IS_AVAILABLE (__cplusplus >= 202302L) + +#endif + +//============================================================================== +// Clang +#if YUP_CLANG + +#if (__clang_major__ < 6) +#error "YUP requires Clang 6 or later" +#endif + +#ifndef YUP_COMPILER_SUPPORTS_ARC +#define YUP_COMPILER_SUPPORTS_ARC 1 +#endif + +#ifndef YUP_EXCEPTIONS_DISABLED +#if ! __has_feature(cxx_exceptions) +#define YUP_EXCEPTIONS_DISABLED 1 +#endif +#endif + +#if ! defined(YUP_SILENCE_XCODE_15_LINKER_WARNING) \ + && defined(__apple_build_version__) \ + && __apple_build_version__ >= 15000000 \ + && __apple_build_version__ < 15000100 + + // Due to known issues, the linker in Xcode 15.0 may produce broken binaries. +#error Please upgrade to Xcode 15.1 or higher +#endif + +#define YUP_CXX20_IS_AVAILABLE (__cplusplus >= 202002L) +#define YUP_CXX23_IS_AVAILABLE (__cplusplus >= 202302L) + +#endif + +//============================================================================== +// MSVC +#if YUP_MSVC + +#if _MSC_FULL_VER < 191025017 // VS2017 +#error "YUP requires Visual Studio 2017 or later" +#endif + +#ifndef YUP_EXCEPTIONS_DISABLED +#if ! _CPPUNWIND +#define YUP_EXCEPTIONS_DISABLED 1 +#endif +#endif + +#define YUP_CXX20_IS_AVAILABLE (_MSVC_LANG == 202002L) +#define YUP_CXX23_IS_AVAILABLE (_MSVC_LANG > 202002L) +#endif + +//============================================================================== +#if ! YUP_CXX20_IS_AVAILABLE +#error "YUP requires C++20 or later" +#endif diff --git a/modules/yup_core/system/yup_StandardHeader.h b/modules/yup_core/system/yup_StandardHeader.h index 8d5929896..3bd1b9ccb 100644 --- a/modules/yup_core/system/yup_StandardHeader.h +++ b/modules/yup_core/system/yup_StandardHeader.h @@ -68,6 +68,7 @@ #include #include #include +#include #include #include #include diff --git a/modules/yup_core/system/yup_TargetPlatform.h b/modules/yup_core/system/yup_TargetPlatform.h index 03f491ac7..7c8043d27 100644 --- a/modules/yup_core/system/yup_TargetPlatform.h +++ b/modules/yup_core/system/yup_TargetPlatform.h @@ -70,15 +70,19 @@ //============================================================================== #if defined(_WIN32) || defined(_WIN64) #define YUP_WINDOWS 1 +#define YUP_DESKTOP 1 #elif defined(ANDROID) || defined(__ANDROID__) #define YUP_ANDROID 1 +#define YUP_MOBILE 1 #elif defined(__FreeBSD__) || defined(__OpenBSD__) #define YUP_BSD 1 +#define YUP_DESKTOP 1 #elif defined(LINUX) || defined(__linux__) #define YUP_LINUX 1 +#define YUP_DESKTOP 1 #elif defined(__APPLE_CPP__) || defined(__APPLE_CC__) #define CF_EXCLUDE_CSTD_HEADERS 1 @@ -87,19 +91,20 @@ #if TARGET_OS_IPHONE || TARGET_IPHONE_SIMULATOR #define YUP_IPHONE 1 #define YUP_IOS 1 +#define YUP_MOBILE 1 #if TARGET_IPHONE_SIMULATOR #define YUP_IOS_SIMULATOR 1 #endif #else #define YUP_MAC 1 +#define YUP_DESKTOP 1 #endif -#elif defined(__wasm__) && defined(__EMSCRIPTEN__) -#define YUP_WASM 1 -#define YUP_EMSCRIPTEN 1 - #elif defined(__wasm__) #define YUP_WASM 1 +#if defined(__EMSCRIPTEN__) +#define YUP_EMSCRIPTEN 1 +#endif #else #error "Unknown platform!" diff --git a/modules/yup_core/xml/yup_XmlElement.h b/modules/yup_core/xml/yup_XmlElement.h index 715082c87..a0d9a81b2 100644 --- a/modules/yup_core/xml/yup_XmlElement.h +++ b/modules/yup_core/xml/yup_XmlElement.h @@ -810,60 +810,4 @@ class YUP_API XmlElement YUP_LEAK_DETECTOR (XmlElement) }; -//============================================================================== -#ifndef DOXYGEN - -/** DEPRECATED: A handy macro to make it easy to iterate all the child elements in an XmlElement. - - New code should avoid this macro, and instead use getChildIterator directly. - - The parentXmlElement should be a reference to the parent XML, and the childElementVariableName - will be the name of a pointer to each child element. - - E.g. @code - XmlElement* myParentXml = createSomeKindOfXmlDocument(); - - forEachXmlChildElement (*myParentXml, child) - { - if (child->hasTagName ("FOO")) - doSomethingWithXmlElement (child); - } - - @endcode - - @see forEachXmlChildElementWithTagName -*/ -#define forEachXmlChildElement(parentXmlElement, childElementVariableName) \ - for (auto*(childElementVariableName) : ((parentXmlElement).macroBasedForLoop(), (parentXmlElement).getChildIterator())) - -/** DEPRECATED: A macro that makes it easy to iterate all the child elements of an XmlElement - which have a specified tag. - - New code should avoid this macro, and instead use getChildWithTagNameIterator directly. - - This does the same job as the forEachXmlChildElement macro, but only for those - elements that have a particular tag name. - - The parentXmlElement should be a reference to the parent XML, and the childElementVariableName - will be the name of a pointer to each child element. The requiredTagName is the - tag name to match. - - E.g. @code - XmlElement* myParentXml = createSomeKindOfXmlDocument(); - - forEachXmlChildElementWithTagName (*myParentXml, child, "MYTAG") - { - // the child object is now guaranteed to be a element.. - doSomethingWithMYTAGElement (child); - } - - @endcode - - @see forEachXmlChildElement -*/ -#define forEachXmlChildElementWithTagName(parentXmlElement, childElementVariableName, requiredTagName) \ - for (auto*(childElementVariableName) : ((parentXmlElement).macroBasedForLoop(), (parentXmlElement).getChildWithTagNameIterator ((requiredTagName)))) - -#endif - } // namespace yup diff --git a/modules/yup_data_model/tree/yup_DataTree.cpp b/modules/yup_data_model/tree/yup_DataTree.cpp index d25873046..dedd4715f 100644 --- a/modules/yup_data_model/tree/yup_DataTree.cpp +++ b/modules/yup_data_model/tree/yup_DataTree.cpp @@ -1710,17 +1710,17 @@ Result DataTree::ValidatedTransaction::addChild (const DataTree& child, int inde ResultValue DataTree::ValidatedTransaction::createAndAddChild (const Identifier& childType, int index) { if (! transaction || ! transaction->isActive() || ! schema) - return ResultValue::fail ("Transaction is not active"); + return makeResultValueFail ("Transaction is not active"); DataTree child = schema->createChildNode (nodeType, childType); if (! child.isValid()) - return ResultValue::fail ("Could not create child of type '" + childType.toString() + "'"); + return makeResultValueFail ("Could not create child of type '" + childType.toString() + "'"); auto addResult = addChild (child, index); if (addResult.failed()) - return ResultValue::fail (addResult.getErrorMessage()); + return makeResultValueFail (addResult.getErrorMessage()); - return ResultValue::ok (child); + return makeResultValueOk (child); } Result DataTree::ValidatedTransaction::removeChild (const DataTree& child) diff --git a/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp b/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp index 6563e65c1..46767963f 100644 --- a/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp +++ b/modules/yup_dsp/stretching/yup_TimeStretchProcessor.cpp @@ -406,26 +406,26 @@ ResultValue TimeStretchProcessor::process (const float* const* inputChannel int outputFrameCount) { if (! prepared || engine == nullptr) - return ResultValue::fail ("TimeStretchProcessor is not prepared"); + return makeResultValueFail ("TimeStretchProcessor is not prepared"); if (inputFrameCount < 0 || outputFrameCount < 0) - return ResultValue::fail ("Invalid frame count"); + return makeResultValueFail ("Invalid frame count"); if (outputFrameCount == 0) - return ResultValue::ok (0); + return makeResultValueOk (0); const bool hasInputProvider = (inputProvider != nullptr); if (! hasInputProvider && inputFrameCount == 0) - return ResultValue::ok (0); + return makeResultValueOk (0); if ((! hasInputProvider && inputChannels == nullptr) || outputChannels == nullptr) - return ResultValue::fail ("Null channel pointers"); + return makeResultValueFail ("Null channel pointers"); if (! hasInputProvider && inputFrameCount > spec.maximumBlockSize) - return ResultValue::fail ("Input frame count exceeds maximum block size"); + return makeResultValueFail ("Input frame count exceeds maximum block size"); const auto processed = engine->process (inputChannels, inputFrameCount, outputChannels, outputFrameCount); - return ResultValue::ok (processed); + return makeResultValueOk (processed); } ResultValue TimeStretchProcessor::process (const AudioBuffer& input, @@ -433,10 +433,10 @@ ResultValue TimeStretchProcessor::process (const AudioBuffer& input, int outputFrameCount) { if (input.getNumChannels() != spec.numChannels || output.getNumChannels() != spec.numChannels) - return ResultValue::fail ("Channel count mismatch"); + return makeResultValueFail ("Channel count mismatch"); if (outputFrameCount > output.getNumSamples()) - return ResultValue::fail ("Output buffer too small"); + return makeResultValueFail ("Output buffer too small"); return process (input.getArrayOfReadPointers(), input.getNumSamples(), @@ -451,7 +451,7 @@ ResultValue TimeStretchProcessor::processUsingTimeRatio (const float* const { const auto expectedOutput = getExpectedOutputFrameCount (inputFrameCount); if (expectedOutput > outputFrameCapacity) - return ResultValue::fail ("Output buffer too small for the current time ratio"); + return makeResultValueFail ("Output buffer too small for the current time ratio"); return process (inputChannels, inputFrameCount, outputChannels, expectedOutput); } @@ -461,7 +461,7 @@ ResultValue TimeStretchProcessor::processUsingTimeRatio (const AudioBuffer< { const auto expectedOutput = getExpectedOutputFrameCount (input.getNumSamples()); if (expectedOutput > output.getNumSamples()) - return ResultValue::fail ("Output buffer too small for the current time ratio"); + return makeResultValueFail ("Output buffer too small for the current time ratio"); return process (input, output, expectedOutput); } diff --git a/modules/yup_dsp/windowing/yup_WindowFunctions.h b/modules/yup_dsp/windowing/yup_WindowFunctions.h index 2e9ddadfa..e9d823991 100644 --- a/modules/yup_dsp/windowing/yup_WindowFunctions.h +++ b/modules/yup_dsp/windowing/yup_WindowFunctions.h @@ -295,6 +295,34 @@ class WindowFunctions return FloatType (1); } + /** Evaluates a Tukey window at a continuous normalised phase value. + + Unlike the discrete tukey(n, N, alpha) variant which operates on integer sample + indices, this overload takes a continuous phase value in [0, 1] directly. It is + useful for per-sample window evaluation in oscillators and modulators where no + fixed buffer length exists. + + @param phi Normalised phase in [0, 1] + @param alpha Taper ratio in (0, 1]: 1.0 produces a full Hann shape, values + near 0 approach a rectangular window + @returns Window amplitude in [0, 1] + */ + static FloatType tukeyFromPhase (FloatType phi, FloatType alpha = FloatType (0.5)) noexcept + { + const FloatType halfAlpha = alpha / FloatType (2); + + if (halfAlpha <= FloatType (0)) + return FloatType (1); + + if (phi < halfAlpha) + return FloatType (0.5) * (FloatType (1) + std::cos (MathConstants::pi * (phi / halfAlpha - FloatType (1)))); + + if (phi > FloatType (1) - halfAlpha) + return FloatType (0.5) * (FloatType (1) + std::cos (MathConstants::pi * ((phi - FloatType (1) + halfAlpha) / halfAlpha))); + + return FloatType (1); + } + static FloatType bartlett (int n, int N) noexcept { return FloatType (1) - FloatType (2) * std::abs (n - (N - 1) / FloatType (2)) / (N - 1); diff --git a/modules/yup_graphics/graphics/yup_Graphics.cpp b/modules/yup_graphics/graphics/yup_Graphics.cpp index 5ca297936..9200d3b27 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.cpp +++ b/modules/yup_graphics/graphics/yup_Graphics.cpp @@ -728,6 +728,9 @@ void Graphics::fillFittedText (const StyledText& text, const Rectangle& r void Graphics::fillFittedText (const String& text, const Font& font, const Rectangle& rect, Justification justification) { + if (text.isEmpty()) + return; + StyledText styledText; { auto modifier = styledText.startUpdate(); @@ -765,6 +768,9 @@ void Graphics::strokeFittedText (const StyledText& text, const Rectangle& void Graphics::strokeFittedText (const String& text, const Font& font, const Rectangle& rect, Justification justification) { + if (text.isEmpty()) + return; + StyledText styledText; { auto modifier = styledText.startUpdate(); diff --git a/modules/yup_graphics/imaging/yup_Image.cpp b/modules/yup_graphics/imaging/yup_Image.cpp index 3073e69bb..2c8284805 100644 --- a/modules/yup_graphics/imaging/yup_Image.cpp +++ b/modules/yup_graphics/imaging/yup_Image.cpp @@ -190,7 +190,7 @@ ResultValue Image::loadFromData (Span imageData) { auto bitmap = rive::Bitmap::decode (imageData.data(), imageData.size()); if (bitmap == nullptr) - return ResultValue::fail ("Unable to decode image"); + return makeResultValueFail ("Unable to decode image"); Image result; @@ -202,7 +202,7 @@ ResultValue Image::loadFromData (Span imageData) : PixelFormat::RGBA, bitmap->detachBytes()); - return ResultValue::ok (result); + return makeResultValueOk (result); } } // namespace yup diff --git a/modules/yup_graphics/native/yup_GraphicsContext_metal.cpp b/modules/yup_graphics/native/yup_GraphicsContext_metal.cpp index 9dc7d5fe3..075de7f42 100644 --- a/modules/yup_graphics/native/yup_GraphicsContext_metal.cpp +++ b/modules/yup_graphics/native/yup_GraphicsContext_metal.cpp @@ -190,10 +190,7 @@ class LowLevelRenderContextMetal : public GraphicsContext m_renderTarget = renderContextImpl->makeRenderTarget (MTLPixelFormatBGRA8Unorm, width, height); if (m_currentTexture != nil) - { - [m_currentTexture setPurgeableState:MTLPurgeableStateEmpty]; m_currentTexture = nil; - } MTLTextureDescriptor* descriptor = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:(MTLPixelFormatBGRA8Unorm) width:width diff --git a/modules/yup_gui/component/yup_ComponentNative.cpp b/modules/yup_gui/component/yup_ComponentNative.cpp index ca2b8321c..815911174 100644 --- a/modules/yup_gui/component/yup_ComponentNative.cpp +++ b/modules/yup_gui/component/yup_ComponentNative.cpp @@ -66,6 +66,15 @@ ComponentNative::Options& ComponentNative::Options::withAllowedHighDensityDispla return *this; } +ComponentNative::Options& ComponentNative::Options::withTemporaryWindow (bool shouldBeTemporary) noexcept +{ + if (shouldBeTemporary) + flags |= temporaryWindow; + else + flags &= ~temporaryWindow; + return *this; +} + ComponentNative::Options& ComponentNative::Options::withGraphicsApi (std::optional newGraphicsApi) noexcept { graphicsApi = newGraphicsApi; diff --git a/modules/yup_gui/component/yup_ComponentNative.h b/modules/yup_gui/component/yup_ComponentNative.h index 3c4d23b64..ab64fdc5f 100644 --- a/modules/yup_gui/component/yup_ComponentNative.h +++ b/modules/yup_gui/component/yup_ComponentNative.h @@ -41,6 +41,7 @@ class YUP_API ComponentNative : public ReferenceCountedObject { struct decoratedWindowTag; struct resizableWindowTag; + struct temporaryWindowTag; struct renderContinuousTag; struct allowHighDensityDisplayTag; @@ -51,7 +52,12 @@ class YUP_API ComponentNative : public ReferenceCountedObject //============================================================================== /** Type definition for window configuration flags. */ - using Flags = FlagSet; + using Flags = FlagSet; /** No flags set. */ static inline constexpr Flags noFlags = Flags(); @@ -59,6 +65,8 @@ class YUP_API ComponentNative : public ReferenceCountedObject static inline constexpr Flags decoratedWindow = Flags::declareValue(); /** Flag to enable window resizing by the user. */ static inline constexpr Flags resizableWindow = Flags::declareValue(); + /** Flag to mark the native window as a temporary popup/menu-style window. */ + static inline constexpr Flags temporaryWindow = Flags::declareValue(); /** Flag to enable continuous rendering mode. */ static inline constexpr Flags renderContinuous = Flags::declareValue(); /** Flag to enable high-density display support. */ @@ -119,6 +127,14 @@ class YUP_API ComponentNative : public ReferenceCountedObject */ Options& withAllowedHighDensityDisplay (bool shouldAllowHighDensity) noexcept; + /** Sets whether the window should be treated as a temporary popup/menu window. + + @param shouldBeTemporary True for popup/menu-style windows, false for regular windows. + + @return Reference to this Options object for method chaining. + */ + Options& withTemporaryWindow (bool shouldBeTemporary) noexcept; + /** Sets the graphics API to be used for rendering. @param newGraphicsApi The graphics API to use, or std::nullopt to use the default. diff --git a/modules/yup_gui/menus/yup_PopupMenu.cpp b/modules/yup_gui/menus/yup_PopupMenu.cpp index 5e040dc1c..1bf617a7c 100644 --- a/modules/yup_gui/menus/yup_PopupMenu.cpp +++ b/modules/yup_gui/menus/yup_PopupMenu.cpp @@ -40,6 +40,24 @@ void removeActivePopup (PopupMenu* popupMenu) } } +PopupMenu* findActivePopupAt (Point globalPos) +{ + for (auto it = activePopups.rbegin(); it != activePopups.rend(); ++it) + { + auto* popupMenu = dynamic_cast (it->get()); + if (popupMenu != nullptr && popupMenu->getScreenBounds().contains (globalPos)) + return popupMenu; + } + + return nullptr; +} + +MouseEvent makePopupMouseEvent (const MouseEvent& event, PopupMenu& popupMenu, Point globalPos) +{ + return event.withPosition (popupMenu.screenToLocal (globalPos)) + .withSourceComponent (&popupMenu); +} + void installGlobalMouseListener() { static bool mouseListenerAdded = [] @@ -48,32 +66,34 @@ void installGlobalMouseListener() { void mouseDown (const MouseEvent& event) override { - Point globalPos = event.getScreenPosition().to(); + const auto globalPos = event.getScreenPosition(); - bool clickedInsidePopup = false; - for (const auto& popup : activePopups) + // Walk the component hierarchy from the event source. + // If any ancestor is a PopupMenu the click is inside a popup — don't dismiss. + auto* comp = event.getSourceComponent(); + while (comp != nullptr) { - auto* popupMenu = dynamic_cast (popup.get()); - if (popupMenu == nullptr) - continue; - - if (popupMenu->getScreenBounds().contains (globalPos)) - { - clickedInsidePopup = true; - break; - } + if (dynamic_cast (comp) != nullptr) + return; + comp = comp->getParentComponent(); + } - // Also check if clicked inside any submenu - if (popupMenu->submenuContains (globalPos)) - { - clickedInsidePopup = true; - break; - } + if (auto* popupMenu = findActivePopupAt (globalPos)) + { + popupMenu->mouseDown (makePopupMouseEvent (event, *popupMenu, globalPos)); + return; } - if (! clickedInsidePopup && ! activePopups.empty()) + if (! activePopups.empty()) PopupMenu::dismissAllPopups(); } + + void mouseMove (const MouseEvent& event) override + { + const auto globalPos = event.getScreenPosition(); + if (auto* popupMenu = findActivePopupAt (globalPos)) + popupMenu->mouseMove (makePopupMouseEvent (event, *popupMenu, globalPos)); + } } globalMouseListener {}; Desktop::getInstance()->addGlobalMouseListener (&globalMouseListener); @@ -293,6 +313,7 @@ bool PopupMenu::Item::isCustomComponent() const PopupMenu::Options::Options() : parentComponent (nullptr) , targetComponent (nullptr) + , focusComponent (nullptr) , alignment (Justification::topLeft) , placement (Placement::below()) , positioningMode (PositioningMode::atPoint) @@ -341,6 +362,12 @@ PopupMenu::Options& PopupMenu::Options::withRelativePosition (Component* compone return *this; } +PopupMenu::Options& PopupMenu::Options::withFocusComponent (Component* component) +{ + this->focusComponent = component; + return *this; +} + PopupMenu::Options& PopupMenu::Options::withMinimumWidth (int minWidth) { this->minWidth = minWidth; @@ -509,7 +536,19 @@ void PopupMenu::positionMenu() availableArea = Rectangle (0, 0, 1920, 1080); // TODO: Move to magic if (auto* desktop = Desktop::getInstance()) { - if (auto screen = desktop->getScreenContaining (this)) + Screen::Ptr screen; + + if (options.positioningMode == PositioningMode::atPoint) + screen = desktop->getScreenContaining (options.targetPosition.to()); + else if (options.positioningMode == PositioningMode::relativeToArea) + screen = desktop->getScreenContaining (options.targetArea.to()); + else if (options.positioningMode == PositioningMode::relativeToComponent && options.targetComponent) + screen = desktop->getScreenContaining (options.targetComponent); + + if (screen == nullptr) + screen = desktop->getScreenContaining (this); + + if (screen != nullptr) availableArea = screen->workArea; } } @@ -605,9 +644,13 @@ void PopupMenu::showCustom (const Options& options, bool isSubmenu, std::functio if (! isSubmenu) dismissAllPopups(); + isBeingDismissed = false; this->options = options; menuCallback = std::move (callback); + if (! isSubmenu) + focusComponentToRestore = getFocusComponentForDismissal(); + if (isEmpty()) { dismiss(); @@ -627,12 +670,14 @@ void PopupMenu::showCustom (const Options& options, bool isSubmenu, std::functio // When we have no parent component, add to desktop to work in screen coordinates auto nativeOptions = ComponentNative::Options {} .withDecoration (false) - .withResizableWindow (false); + .withResizableWindow (false) + .withTemporaryWindow (true); if (! isOnDesktop()) addToDesktop (nativeOptions); } + removeActivePopup (this); activePopups.push_back (this); setupMenuItems(); @@ -660,8 +705,13 @@ void PopupMenu::dismiss (int itemID) setVisible (false); + if (getParentComponent() == nullptr && isOnDesktop()) + removeFromDesktop(); + selectedItemIndex = -1; + restoreFocusAfterDismissal(); + if (auto itemCallback = std::exchange (menuCallback, {})) itemCallback (itemID); @@ -680,6 +730,34 @@ bool PopupMenu::isBeingShown() const //============================================================================== +Component* PopupMenu::getFocusComponentForDismissal() const +{ + if (options.focusComponent != nullptr) + return options.focusComponent; + + auto* referenceComponent = options.targetComponent != nullptr ? options.targetComponent + : options.parentComponent; + if (referenceComponent == nullptr) + return nullptr; + + if (auto* nativeComponent = referenceComponent->getNativeComponent()) + return nativeComponent->getFocusedComponent(); + + return nullptr; +} + +void PopupMenu::restoreFocusAfterDismissal() +{ + auto focusToRestore = std::exchange (focusComponentToRestore, {}); + if (focusToRestore == nullptr || focusToRestore == this) + return; + + if (auto* nativeToRestore = focusToRestore->getNativeComponent()) + nativeToRestore->setFocusedComponent (focusToRestore.get()); +} + +//============================================================================== + void PopupMenu::paint (Graphics& g) { if (auto style = ApplicationTheme::findComponentStyle (*this)) @@ -704,18 +782,33 @@ void PopupMenu::mouseDown (const MouseEvent& event) if (item.isSeparator() || ! item.isEnabled) return; + setSelectedItemIndex (itemIndex, true); + if (item.isSubMenu()) { // For submenus, we show them on hover, not on click showSubmenu (itemIndex); } - else - { - // Hide any visible submenus when selecting a non-separator item - hideSubmenus(); +} - dismiss (item.itemID); +void PopupMenu::mouseUp (const MouseEvent& event) +{ + if (! getLocalBounds().contains (event.getPosition())) + { + dismiss(); + return; } + + auto itemIndex = getItemIndexAt (event.getPosition()); + if (! isPositiveAndBelow (itemIndex, getNumItems())) + return; + + auto& item = *items[itemIndex]; + if (item.isSeparator() || ! item.isEnabled || item.isSubMenu()) + return; + + hideSubmenus(); + dismiss (item.itemID); } void PopupMenu::mouseMove (const MouseEvent& event) @@ -752,7 +845,13 @@ void PopupMenu::mouseEnter (const MouseEvent& event) { int itemIndex = getItemIndexAt (event.getPosition()); if (itemIndex >= 0 && isItemSelectable (itemIndex)) + { setSelectedItemIndex (itemIndex, true); + + auto& item = *items[itemIndex]; + if (item.isSubMenu() && item.isEnabled) + showSubmenu (itemIndex); + } } void PopupMenu::mouseExit (const MouseEvent& event) @@ -895,7 +994,10 @@ PopupMenu::Placement PopupMenu::calculateSubmenuPlacement (Rectangle item availableArea = Rectangle (0, 0, 1920, 1080); // TODO: move to magic if (auto* desktop = Desktop::getInstance()) { - if (auto screen = desktop->getPrimaryScreen()) + Screen::Ptr screen = desktop->getScreenContaining (getScreenBounds().to()); + if (screen == nullptr) + screen = desktop->getPrimaryScreen(); + if (screen != nullptr) availableArea = screen->workArea.to(); } menuBounds = getScreenBounds().to(); @@ -980,9 +1082,11 @@ void PopupMenu::cleanupSubmenu (PopupMenu::Ptr submenu) void PopupMenu::resetInternalState() { + hideSubmenus(); + // Reset flags that might prevent re-showing isBeingDismissed = false; - setSelectedItemIndex (-1, true); + setSelectedItemIndex (-1, false); // Reset scrolling state for scrollable menus visibleItemRange = Range (0, 0); @@ -1080,7 +1184,12 @@ void PopupMenu::selectCurrentItem() void PopupMenu::setSelectedItemIndex (int index, bool fromMouse) { if (selectedItemIndex == index) + { + if (fromMouse) + updateSubmenuVisibility (index); + return; + } if (selectedItemIndex >= 0 && selectedItemIndex < static_cast (items.size())) items[selectedItemIndex]->isHovered = false; @@ -1250,18 +1359,31 @@ void PopupMenu::calculateAvailableHeight() // Use screen bounds if (auto* desktop = Desktop::getInstance()) { - if (auto screen = desktop->getPrimaryScreen()) + Screen::Ptr screen; + + float menuY = 0.0f; + if (options.positioningMode == PositioningMode::atPoint) { - auto screenBounds = screen->workArea.to(); + menuY = options.targetPosition.getY(); + screen = desktop->getScreenContaining (options.targetPosition.to()); + } + else if (options.positioningMode == PositioningMode::relativeToArea) + { + menuY = options.targetArea.getY(); + screen = desktop->getScreenContaining (options.targetArea.to()); + } + else if (options.positioningMode == PositioningMode::relativeToComponent && options.targetComponent) + { + menuY = options.targetComponent->getScreenBounds().getY(); + screen = desktop->getScreenContaining (options.targetComponent); + } + + if (screen == nullptr) + screen = desktop->getPrimaryScreen(); - // Estimate menu position for screen coordinate calculation - float menuY = 0.0f; - if (options.positioningMode == PositioningMode::atPoint) - menuY = options.targetPosition.getY(); - else if (options.positioningMode == PositioningMode::relativeToArea) - menuY = options.targetArea.getY(); - else if (options.positioningMode == PositioningMode::relativeToComponent && options.targetComponent) - menuY = options.targetComponent->getScreenBounds().getY(); + if (screen != nullptr) + { + auto screenBounds = screen->workArea.to(); availableContentHeight = screenBounds.getBottom() - menuY; availableContentHeight = jmax (100.0f, availableContentHeight); diff --git a/modules/yup_gui/menus/yup_PopupMenu.h b/modules/yup_gui/menus/yup_PopupMenu.h index 6a7f752f1..3090bcd0f 100644 --- a/modules/yup_gui/menus/yup_PopupMenu.h +++ b/modules/yup_gui/menus/yup_PopupMenu.h @@ -126,6 +126,16 @@ class YUP_API PopupMenu */ Options& withRelativePosition (Component* component, Placement placement = Placement::below()); + /** Sets the component that should regain focus when the menu closes. + + This does not affect menu positioning or parenting. It is useful for desktop popup + menus that are shown in their own native window but should return focus to the + component that opened them. + + @param component The component to focus after the menu closes + */ + Options& withFocusComponent (Component* component); + /** Minimum width for the menu. */ Options& withMinimumWidth (int minWidth); @@ -134,6 +144,7 @@ class YUP_API PopupMenu Component* parentComponent; Component* targetComponent; + Component* focusComponent; Point targetPosition; Rectangle targetArea; Justification alignment; @@ -322,6 +333,8 @@ class YUP_API PopupMenu /** @internal */ void mouseDown (const MouseEvent& event) override; /** @internal */ + void mouseUp (const MouseEvent& event) override; + /** @internal */ void mouseMove (const MouseEvent& event) override; /** @internal */ void mouseEnter (const MouseEvent& event) override; @@ -342,6 +355,8 @@ class YUP_API PopupMenu void setupMenuItems(); void positionMenu(); void resetInternalState(); + Component* getFocusComponentForDismissal() const; + void restoreFocusAfterDismissal(); // Submenu functionality void showSubmenu (int itemIndex); @@ -391,6 +406,7 @@ class YUP_API PopupMenu Options options; int selectedItemIndex = -1; // For keyboard navigation bool isBeingDismissed = false; + WeakReference focusComponentToRestore; std::function menuCallback; diff --git a/modules/yup_gui/native/yup_FileChooser_mac.mm b/modules/yup_gui/native/yup_FileChooser_mac.mm index 0800c9227..bb318bb77 100644 --- a/modules/yup_gui/native/yup_FileChooser_mac.mm +++ b/modules/yup_gui/native/yup_FileChooser_mac.mm @@ -71,102 +71,99 @@ //============================================================================== void FileChooser::showPlatformDialog(CompletionCallback callback, int flags) { - YUP_AUTORELEASEPOOL + const bool isSave = (flags & saveMode) != 0; + const bool canChooseFiles = (flags & canSelectFiles) != 0; + const bool canChooseDirectories = (flags & canSelectDirectories) != 0; + const bool allowsMultipleSelection = (flags & canSelectMultipleItems) != 0; + const bool warnAboutOverwrite = (flags & warnAboutOverwriting) != 0; + + if (isSave) { - const bool isSave = (flags & saveMode) != 0; - const bool canChooseFiles = (flags & canSelectFiles) != 0; - const bool canChooseDirectories = (flags & canSelectDirectories) != 0; - const bool allowsMultipleSelection = (flags & canSelectMultipleItems) != 0; - const bool warnAboutOverwrite = (flags & warnAboutOverwriting) != 0; + NSSavePanel* panel = [NSSavePanel savePanel]; - if (isSave) - { - NSSavePanel* panel = [NSSavePanel savePanel]; + [panel setTitle:[NSString stringWithUTF8String:title.toUTF8()]]; + [panel setCanCreateDirectories:YES]; + [panel setShowsHiddenFiles:NO]; - [panel setTitle:[NSString stringWithUTF8String:title.toUTF8()]]; - [panel setCanCreateDirectories:YES]; - [panel setShowsHiddenFiles:NO]; + if (warnAboutOverwrite) + [panel setExtensionHidden:NO]; - if (warnAboutOverwrite) - [panel setExtensionHidden:NO]; + NSArray* allowedTypes = createAllowedFileTypes(filters); + if (allowedTypes != nil) + { + [panel setAllowedFileTypes:allowedTypes]; + [panel setAllowsOtherFileTypes:NO]; + } - NSArray* allowedTypes = createAllowedFileTypes(filters); - if (allowedTypes != nil) + if (startingFile.exists()) + { + if (startingFile.isDirectory()) { - [panel setAllowedFileTypes:allowedTypes]; - [panel setAllowsOtherFileTypes:NO]; + [panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:startingFile.getFullPathName().toUTF8()]]]; } - - if (startingFile.exists()) + else { - if (startingFile.isDirectory()) - { - [panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:startingFile.getFullPathName().toUTF8()]]]; - } - else - { - [panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:startingFile.getParentDirectory().getFullPathName().toUTF8()]]]; - [panel setNameFieldStringValue:[NSString stringWithUTF8String:startingFile.getFileName().toUTF8()]]; - } + [panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:startingFile.getParentDirectory().getFullPathName().toUTF8()]]]; + [panel setNameFieldStringValue:[NSString stringWithUTF8String:startingFile.getFileName().toUTF8()]]; } + } - [panel beginWithCompletionHandler:^(NSModalResponse result) { - Array results; + [panel beginWithCompletionHandler:^(NSModalResponse result) { + Array results; - if (result == NSModalResponseOK) + if (result == NSModalResponseOK) + { + auto* url = [panel URL]; + if (url != nil) { - auto* url = [panel URL]; - if (url != nil) - { - NSString* path = [url path]; - if (path != nil) - results.add(File(String::fromUTF8([path UTF8String]))); - } + NSString* path = [url path]; + if (path != nil) + results.add(File(String::fromUTF8([path UTF8String]))); } + } - MessageManager::callAsync([this, callback = std::move(callback), result, results] - { invokeCallback(std::move(callback), result == NSModalResponseOK, results); }); - }]; - } - else - { - NSOpenPanel* panel = [NSOpenPanel openPanel]; + MessageManager::callAsync([this, callback = std::move(callback), result, results] + { invokeCallback(std::move(callback), result == NSModalResponseOK, results); }); + }]; + } + else + { + NSOpenPanel* panel = [NSOpenPanel openPanel]; - [panel setTitle:[NSString stringWithUTF8String:title.toUTF8()]]; - [panel setCanChooseFiles:canChooseFiles]; - [panel setCanChooseDirectories:canChooseDirectories]; - [panel setAllowsMultipleSelection:allowsMultipleSelection]; - [panel setShowsHiddenFiles:NO]; - [panel setTreatsFilePackagesAsDirectories:packageDirsAsFiles]; + [panel setTitle:[NSString stringWithUTF8String:title.toUTF8()]]; + [panel setCanChooseFiles:canChooseFiles]; + [panel setCanChooseDirectories:canChooseDirectories]; + [panel setAllowsMultipleSelection:allowsMultipleSelection]; + [panel setShowsHiddenFiles:NO]; + [panel setTreatsFilePackagesAsDirectories:packageDirsAsFiles]; - NSArray* allowedTypes = createAllowedFileTypes(filters); - if (allowedTypes != nil && canChooseFiles) - { - [panel setAllowedFileTypes:allowedTypes]; - [panel setAllowsOtherFileTypes:NO]; - } + NSArray* allowedTypes = createAllowedFileTypes(filters); + if (allowedTypes != nil && canChooseFiles) + { + [panel setAllowedFileTypes:allowedTypes]; + [panel setAllowsOtherFileTypes:NO]; + } - if (startingFile.exists()) - [panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:startingFile.getFullPathName().toUTF8()]]]; + if (startingFile.exists()) + [panel setDirectoryURL:[NSURL fileURLWithPath:[NSString stringWithUTF8String:startingFile.getFullPathName().toUTF8()]]]; - [panel beginWithCompletionHandler:^(NSModalResponse result) { - Array results; + [panel beginWithCompletionHandler:^(NSModalResponse result) { + Array results; - auto* urls = [panel URLs]; - if (urls != nil) + auto* urls = [panel URLs]; + if (urls != nil) + { + for (NSURL* url in urls) { - for (NSURL* url in urls) - { - NSString* path = [url path]; - if (path != nil) - results.add(File(String::fromUTF8([path UTF8String]))); - } + NSString* path = [url path]; + if (path != nil) + results.add(File(String::fromUTF8([path UTF8String]))); } + } - MessageManager::callAsync([this, callback = std::move(callback), result, results] - { invokeCallback(std::move(callback), result == NSModalResponseOK, results); }); - }]; - } + MessageManager::callAsync([this, callback = std::move(callback), result, results] + { invokeCallback(std::move(callback), result == NSModalResponseOK, results); }); + }]; } } diff --git a/modules/yup_gui/native/yup_WindowingHelpers.h b/modules/yup_gui/native/yup_WindowingHelpers.h index e9b85358b..81fa41f49 100644 --- a/modules/yup_gui/native/yup_WindowingHelpers.h +++ b/modules/yup_gui/native/yup_WindowingHelpers.h @@ -34,4 +34,11 @@ namespace yup */ Rectangle getNativeWindowPosition (void* nativeWindow); +/** + Gives focus to a native window handle when supported by the current platform. + + @param nativeWindow The native window handle. + */ +void focusNativeWindow (void* nativeWindow); + } // namespace yup diff --git a/modules/yup_gui/native/yup_WindowingUtilities_sdl2.cpp b/modules/yup_gui/native/yup_WindowingUtilities_sdl2.cpp index 1d7f003e8..5681b14c3 100644 --- a/modules/yup_gui/native/yup_WindowingUtilities_sdl2.cpp +++ b/modules/yup_gui/native/yup_WindowingUtilities_sdl2.cpp @@ -42,6 +42,22 @@ MouseEvent::Buttons toMouseButton (Uint8 sdlButton) noexcept } } +MouseEvent::Buttons toMouseButtons (Uint32 sdlButtons) noexcept +{ + int buttons = MouseEvent::Buttons::noButtons; + + if ((sdlButtons & SDL_BUTTON (SDL_BUTTON_LEFT)) != 0) + buttons |= MouseEvent::Buttons::leftButton; + + if ((sdlButtons & SDL_BUTTON (SDL_BUTTON_RIGHT)) != 0) + buttons |= MouseEvent::Buttons::rightButton; + + if ((sdlButtons & SDL_BUTTON (SDL_BUTTON_MIDDLE)) != 0) + buttons |= MouseEvent::Buttons::middleButton; + + return static_cast (buttons); +} + //============================================================================== KeyModifiers toKeyModifiers (Uint16 sdlMod) noexcept @@ -272,6 +288,11 @@ Rectangle getNativeWindowPosition (void* nativeWindow) { return {}; } + +void focusNativeWindow (void* nativeWindow) +{ + ignoreUnused (nativeWindow); +} #endif //============================================================================== diff --git a/modules/yup_gui/native/yup_Windowing_linux.cpp b/modules/yup_gui/native/yup_Windowing_linux.cpp index 93882c4f1..fb2ea6bbe 100644 --- a/modules/yup_gui/native/yup_Windowing_linux.cpp +++ b/modules/yup_gui/native/yup_Windowing_linux.cpp @@ -98,4 +98,9 @@ Rectangle getNativeWindowPosition (void* nativeWindow) return {}; } +void focusNativeWindow (void* nativeWindow) +{ + ignoreUnused (nativeWindow); +} + } // namespace yup diff --git a/modules/yup_gui/native/yup_Windowing_mac.mm b/modules/yup_gui/native/yup_Windowing_mac.mm index 4cc61c684..39c2fd94c 100644 --- a/modules/yup_gui/native/yup_Windowing_mac.mm +++ b/modules/yup_gui/native/yup_Windowing_mac.mm @@ -52,4 +52,18 @@ static_cast(NSHeight(windowRect))}; } +void focusNativeWindow (void* nativeWindow) +{ + if (nativeWindow == nullptr) + return; + + id nativeObject = (__bridge id) nativeWindow; + NSWindow* window = [nativeObject isKindOfClass:[NSWindow class]] + ? (NSWindow*) nativeObject + : [nativeObject window]; + + [NSApp activateIgnoringOtherApps:YES]; + [window makeKeyAndOrderFront:nil]; +} + } // namespace yup diff --git a/modules/yup_gui/native/yup_Windowing_sdl2.cpp b/modules/yup_gui/native/yup_Windowing_sdl2.cpp index 360e2ae24..4b0421ba8 100644 --- a/modules/yup_gui/native/yup_Windowing_sdl2.cpp +++ b/modules/yup_gui/native/yup_Windowing_sdl2.cpp @@ -23,11 +23,7 @@ namespace yup { //============================================================================== -#ifndef YUP_WINDOWING_LOGGING -#define YUP_WINDOWING_LOGGING 1 -#endif - -#if YUP_WINDOWING_LOGGING +#if YUP_ENABLE_WINDOWING_EVENT_LOGGING #define YUP_WINDOWING_LOG(textToWrite) YUP_DBG (textToWrite) #else #define YUP_WINDOWING_LOG(textToWrite) \ @@ -75,6 +71,9 @@ SDL2ComponentNative::SDL2ComponentNative (Component& component, if (options.flags.test (allowHighDensityDisplay)) windowFlags |= SDL_WINDOW_ALLOW_HIGHDPI; + if (options.flags.test (temporaryWindow)) + windowFlags |= SDL_WINDOW_POPUP_MENU | SDL_WINDOW_SKIP_TASKBAR | SDL_WINDOW_ALWAYS_ON_TOP; + if (! options.flags.test (decoratedWindow)) windowFlags |= SDL_WINDOW_BORDERLESS; @@ -409,10 +408,13 @@ void SDL2ComponentNative::setFocusedComponent (Component* comp) lastComponentFocused->repaint(); } - if (window != nullptr) + if (window != nullptr && isVisible() && lastComponentFocused != nullptr) { - if ((SDL_GetWindowFlags (window) & SDL_WINDOW_INPUT_FOCUS) == 0) // SDL_WINDOW_MOUSE_FOCUS - SDL_SetWindowInputFocus (window); + SDL_RaiseWindow (window); + + focusNativeWindow (getNativeHandle()); + + SDL_SetWindowInputFocus (window); } } @@ -819,7 +821,7 @@ void SDL2ComponentNative::handleMouseMoveOrDrag (const Point& position) void SDL2ComponentNative::handleMouseDown (const Point& position, MouseEvent::Buttons button, KeyModifiers modifiers) { - currentMouseButtons = static_cast (currentMouseButtons | button); + currentMouseButtons = static_cast (toMouseButtons (SDL_GetMouseState (nullptr, nullptr)) | button); currentKeyModifiers = modifiers; auto event = MouseEvent() @@ -827,7 +829,7 @@ void SDL2ComponentNative::handleMouseDown (const Point& position, MouseEv .withModifiers (currentKeyModifiers) .withPosition (position); - if (lastComponentClicked == nullptr) + if (currentMouseButtons == button) lastComponentClicked = findComponentForMouseEvent (position); if (lastComponentClicked != nullptr) @@ -847,7 +849,7 @@ void SDL2ComponentNative::handleMouseDown (const Point& position, MouseEv void SDL2ComponentNative::handleMouseUp (const Point& position, MouseEvent::Buttons button, KeyModifiers modifiers) { - currentMouseButtons = static_cast (currentMouseButtons & ~button); + currentMouseButtons = static_cast (toMouseButtons (SDL_GetMouseState (nullptr, nullptr)) & ~button); currentKeyModifiers = modifiers; auto event = MouseEvent() @@ -861,24 +863,31 @@ void SDL2ComponentNative::handleMouseUp (const Point& position, MouseEven if (lastMouseDownTime) event = event.withLastMouseDownTime (*lastMouseDownTime); - if (lastComponentClicked != nullptr) + auto nativeBailOut = Component::BailOutChecker (&component); + + if (auto* clickedComponent = lastComponentClicked.get()) { const auto currentMouseDownTime = yup::Time::getCurrentTime(); + auto clickedComponentBailOut = Component::BailOutChecker (clickedComponent); - event = event.withSourceComponent (lastComponentClicked); + event = event.withSourceComponent (clickedComponent); if (lastMouseUpTime && *lastMouseUpTime > yup::Time() && currentMouseDownTime - *lastMouseUpTime < doubleClickTime) { - lastComponentClicked->internalMouseDoubleClick (event.withRelativePositionTo (lastComponentClicked)); + clickedComponent->internalMouseDoubleClick (event.withRelativePositionTo (clickedComponent)); } - lastComponentClicked->internalMouseUp (event.withRelativePositionTo (lastComponentClicked)); + if (! clickedComponentBailOut.shouldBailOut()) + clickedComponent->internalMouseUp (event.withRelativePositionTo (clickedComponent)); lastMouseUpTime = currentMouseDownTime; } + if (nativeBailOut.shouldBailOut()) + return; + if (currentMouseButtons == MouseEvent::noButtons) { updateComponentUnderMouse (event); @@ -1031,7 +1040,8 @@ void SDL2ComponentNative::handleResized (int width, int height) setPosition (nativeWindowPos.getTopLeft()); } - PopupMenu::dismissAllPopups(); + if (dynamic_cast (&component) == nullptr) + PopupMenu::dismissAllPopups(); repaint(); } @@ -1421,8 +1431,11 @@ int SDL2ComponentNative::eventDispatcher (void* userdata, SDL_Event* event) default: { - if (auto nativeComponent = Desktop::getInstance()->getNativeComponent (userdata)) - dynamic_cast (nativeComponent.get())->handleEvent (event); + if (auto component = Desktop::getInstance()->getNativeComponent (userdata)) + { + if (auto nativeComponent = dynamic_cast (component.get())) + nativeComponent->handleEvent (event); + } break; } @@ -1609,7 +1622,7 @@ void Desktop::setMouseCursor (const MouseCursor& cursorToSet) { MouseCursor::ResizeUpDown, SDL_CreateSystemCursor (SDL_SYSTEM_CURSOR_SIZENS) }, { MouseCursor::ResizeTopLeftRightBottom, SDL_CreateSystemCursor (SDL_SYSTEM_CURSOR_SIZENWSE) }, { MouseCursor::ResizeBottomLeftRightTop, SDL_CreateSystemCursor (SDL_SYSTEM_CURSOR_SIZENESW) }, - { MouseCursor::ResizeAll, SDL_CreateSystemCursor (SDL_SYSTEM_CURSOR_ARROW) } + { MouseCursor::ResizeAll, SDL_CreateSystemCursor (SDL_SYSTEM_CURSOR_SIZEALL) } }; }(); diff --git a/modules/yup_gui/native/yup_Windowing_windows.cpp b/modules/yup_gui/native/yup_Windowing_windows.cpp index bd62203d8..c32a94d82 100644 --- a/modules/yup_gui/native/yup_Windowing_windows.cpp +++ b/modules/yup_gui/native/yup_Windowing_windows.cpp @@ -38,4 +38,13 @@ Rectangle getNativeWindowPosition (void* nativeWindow) }; } +void focusNativeWindow (void* nativeWindow) +{ + auto hwnd = reinterpret_cast (nativeWindow); + if (hwnd == nullptr) + return; + + SetForegroundWindow (hwnd); +} + } // namespace yup diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index 8d1cb6526..d319d1503 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -160,10 +160,11 @@ void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider sliderValue * sliderBounds.getHeight(), 2.0f); } - - // Draw thumb - g.setFillColor (isMouseDown ? colors.thumbDown : (isMouseOver ? colors.thumbOver : colors.thumb)); - g.fillEllipse (thumbBounds); + else + { + g.setFillColor (isMouseDown ? colors.thumbDown : (isMouseOver ? colors.thumbOver : colors.thumb)); + g.fillEllipse (thumbBounds); + } // Draw focus outline if needed if (slider.hasKeyboardFocus()) @@ -944,6 +945,265 @@ void paintListBoxItem (Graphics& g, const ApplicationTheme& theme, const ListBox //============================================================================== #if YUP_MODULE_AVAILABLE_yup_audio_gui +constexpr float audioGraphNodeBaseHeaderHeight = 32.0f; +constexpr float audioGraphNodeBaseParameterRowHeight = 25.0f; +constexpr float audioGraphNodeBaseCornerRadius = 7.0f; +constexpr float audioGraphNodeBaseContentHeight = 8.0f; + +Rectangle audioGraphEllipseBounds (Point center, float radius) +{ + return { center.getX() - radius, center.getY() - radius, radius * 2.0f, radius * 2.0f }; +} + +void fillAudioGraphFeatheredRoundedRect (Graphics& g, Rectangle bounds, float corner, Color color, float viewScale) +{ + constexpr int numLayers = 5; + + for (int i = numLayers; i > 0; --i) + { + const auto amount = static_cast (i) * 1.4f * viewScale; + const auto alpha = 0.020f + (static_cast (numLayers - i) * 0.018f); + g.setFillColor (color.withAlpha (alpha)); + g.fillRoundedRect (bounds.reduced (-amount), corner + amount); + } +} + +void strokeAudioGraphBezierHalf (Graphics& g, Point p0, Point p1, Point p2, Point p3, Color color, float strokeWidth, bool firstHalf) +{ + const auto p01 = (p0 + p1) * 0.5f; + const auto p12 = (p1 + p2) * 0.5f; + const auto p23 = (p2 + p3) * 0.5f; + const auto p012 = (p01 + p12) * 0.5f; + const auto p123 = (p12 + p23) * 0.5f; + const auto midpoint = (p012 + p123) * 0.5f; + + Path path; + if (firstHalf) + path.moveTo (p0).cubicTo (p01, p012.getX(), p012.getY(), midpoint.getX(), midpoint.getY()); + else + path.moveTo (midpoint).cubicTo (p123, p23.getX(), p23.getY(), p3.getX(), p3.getY()); + + g.setStrokeColor (color); + g.setStrokeWidth (strokeWidth); + g.setStrokeCap (StrokeCap::Round); + g.strokePath (path); +} + +void paintAudioGraphConnection (Graphics& g, const AudioGraphComponent& graph, const AudioGraphConnection& connection, float opacity) +{ + const auto zoom = graph.getZoom(); + const auto start = graph.getEndpointScreenPosition (connection.source); + const auto end = graph.getEndpointScreenPosition (connection.destination); + + if (! graph.getLocalBounds().reduced (-200.0f).contains (start) && ! graph.getLocalBounds().reduced (-200.0f).contains (end)) + return; + + const auto controlOffset = jmax (60.0f * zoom, std::abs (end.getX() - start.getX()) * 0.5f); + const auto cp1 = Point { start.getX() + controlOffset, start.getY() }; + const auto cp2 = Point { end.getX() - controlOffset, end.getY() }; + const auto strokeWidth = jmax (1.5f, 3.0f * zoom); + + strokeAudioGraphBezierHalf (g, start, cp1, cp2, end, graph.getEndpointColor (connection.source).withMultipliedAlpha (opacity), strokeWidth, true); + strokeAudioGraphBezierHalf (g, start, cp1, cp2, end, graph.getEndpointColor (connection.destination).withMultipliedAlpha (opacity), strokeWidth, false); +} + +void paintAudioGraphPendingWire (Graphics& g, const AudioGraphComponent& graph) +{ + if (! graph.isPendingWireVisible()) + return; + + const auto activeEndpoint = graph.getPendingWireEndpoint(); + if (! activeEndpoint.has_value()) + return; + + const auto zoom = graph.getZoom(); + const auto start = graph.getEndpointScreenPosition (*activeEndpoint); + const auto end = graph.getPendingWireEndPosition(); + const auto controlOffset = jmax (60.0f * zoom, std::abs (end.getX() - start.getX()) * 0.5f); + const auto cp1 = Point { start.getX() + (activeEndpoint->isSource() ? controlOffset : -controlOffset), start.getY() }; + const auto cp2 = Point { end.getX() - (activeEndpoint->isSource() ? controlOffset : -controlOffset), end.getY() }; + + Path path; + path.moveTo (start).cubicTo (cp1, cp2.getX(), cp2.getY(), end.getX(), end.getY()); + + const auto endpointColor = graph.getEndpointColor (*activeEndpoint); + g.setStrokeColor (endpointColor.withAlpha (0.55f)); + g.setStrokeWidth (jmax (1.5f, 2.5f * zoom)); + g.setStrokeCap (StrokeCap::Round); + g.strokePath (path); + + g.setFillColor (endpointColor.withAlpha (0.20f)); + const auto center = graph.getEndpointScreenPosition (*activeEndpoint); + const auto radius = 14.0f * zoom; + g.fillEllipse (center.getX() - radius, center.getY() - radius, radius * 2.0f, radius * 2.0f); +} + +void paintAudioGraphComponent (Graphics& g, const ApplicationTheme&, const AudioGraphComponent& graph) +{ + const auto zoom = graph.getZoom(); + const auto canvasOffset = graph.getCanvasOffset(); + const auto backgroundColor = graph.findColor (AudioGraphComponent::Style::backgroundColorId) + .value_or (Color (0xff101522)); + const auto gridColor = graph.findColor (AudioGraphComponent::Style::gridColorId) + .value_or (Colors::white.withAlpha (0.045f)); + + g.setFillColor (backgroundColor); + g.fillAll(); + + const auto spacing = 24.0f * zoom; + if (spacing >= 6.0f && ! gridColor.isTransparent()) + { + const auto startX = std::fmod (canvasOffset.getX(), spacing); + const auto startY = std::fmod (canvasOffset.getY(), spacing); + + g.setFillColor (gridColor); + + for (auto x = startX; x < graph.getWidth(); x += spacing) + { + for (auto y = startY; y < graph.getHeight(); y += spacing) + g.fillEllipse (x - 1.0f, y - 1.0f, 2.0f, 2.0f); + } + } + + if (const auto* processor = graph.getGraphProcessor()) + { + for (const auto& connection : processor->getConnections()) + paintAudioGraphConnection (g, graph, connection, 1.0f); + } + + paintAudioGraphPendingWire (g, graph); +} + +void paintAudioGraphPort (Graphics& g, + const AudioGraphNodeView& node, + const Font& labelFont, + const AudioGraphNodeView::PortInfo& info, + Point center, + float viewScale, + bool isInput) +{ + const auto portRadius = node.getPortRadius(); + const auto portHoleColor = node.findColor (AudioGraphNodeView::Style::portHoleColorId) + .value_or (Color (0xff101522)); + const auto textColor = node.findColor (AudioGraphNodeView::Style::textColorId) + .value_or (Color (0xffd6d6d6)); + + g.setFillColor (info.color.withAlpha (0.24f)); + g.fillEllipse (audioGraphEllipseBounds (center, portRadius * 1.8f)); + g.setFillColor (info.color); + g.fillEllipse (audioGraphEllipseBounds (center, portRadius)); + g.setFillColor (portHoleColor); + g.fillEllipse (audioGraphEllipseBounds (center, portRadius * 0.45f)); + + g.setFillColor (textColor); + + if (isInput) + g.fillFittedText (info.name, labelFont, { center.getX() + 12.0f * viewScale, center.getY() - 8.0f * viewScale, 72.0f * viewScale, 16.0f * viewScale }, Justification::left); + else + g.fillFittedText (info.name, labelFont, { center.getX() - 84.0f * viewScale, center.getY() - 8.0f * viewScale, 72.0f * viewScale, 16.0f * viewScale }, Justification::right); +} + +void paintAudioGraphNodeView (Graphics& g, const ApplicationTheme& theme, const AudioGraphNodeView& node) +{ + const auto viewScale = node.getViewScale(); + const auto bounds = node.getLocalBounds().reduced (node.getPortRadius() * 0.5f, 0.0f); + const auto bodyBounds = bounds.reduced (2.0f * viewScale); + const auto corner = audioGraphNodeBaseCornerRadius * viewScale; + const auto accent = node.getNodeColor(); + const auto headerHeight = audioGraphNodeBaseHeaderHeight * viewScale; + + const auto shadowColor = node.findColor (AudioGraphNodeView::Style::shadowColorId) + .value_or (Colors::black); + const auto accentBackgroundColor = node.findColor (AudioGraphNodeView::Style::accentBackgroundColorId) + .value_or (accent); + const auto bodyBackgroundColor = node.findColor (AudioGraphNodeView::Style::bodyBackgroundColorId) + .value_or (Color (0xff1e2535)); + const auto headerBackgroundColor = node.findColor (AudioGraphNodeView::Style::headerBackgroundColorId) + .value_or (Color (0xff141a26)); + const auto textColor = node.findColor (AudioGraphNodeView::Style::textColorId) + .value_or (Color (0xffd6d6d6)); + const auto subtitleTextColor = node.findColor (AudioGraphNodeView::Style::subtitleTextColorId) + .value_or (Color (0xffb8b8b8)); + const auto parameterBackgroundColor = node.findColor (AudioGraphNodeView::Style::parameterBackgroundColorId) + .value_or (Color (0xff263044)); + const auto parameterValueBackgroundColor = node.findColor (AudioGraphNodeView::Style::parameterValueBackgroundColorId) + .value_or (Color (0xff1a2130)); + + fillAudioGraphFeatheredRoundedRect (g, bodyBounds.translated (0.0f, 3.0f * viewScale), corner, shadowColor, viewScale); + + g.setFillColor (accentBackgroundColor.withAlpha (0.11f)); + g.fillRoundedRect (bodyBounds.reduced (-1.5f * viewScale), corner + 2.0f * viewScale); + + g.setFillColor (bodyBackgroundColor); + g.fillRoundedRect (bodyBounds, corner); + + auto headerBounds = bodyBounds.withHeight (headerHeight); + g.setFillColor (headerBackgroundColor); + g.fillRoundedRect (headerBounds, corner, corner, 0.0f, 0.0f); + + g.setStrokeColor (accent.withAlpha (0.70f)); + g.setStrokeWidth (1.35f * viewScale); + g.strokeRoundedRect (bodyBounds, corner); + + const auto headerInner = headerBounds.reduced (9.0f * viewScale, 5.0f * viewScale); + const auto font = theme.getDefaultFont(); + + g.setFillColor (accent); + g.fillFittedText (node.getNodeTitle(), font.withHeight (12.0f * viewScale), headerInner.withHeight (18.0f * viewScale), Justification::left); + + const auto subtitle = node.getNodeSubtitle(); + if (subtitle.isNotEmpty()) + { + g.setFillColor (subtitleTextColor); + g.fillFittedText (subtitle, font.withHeight (9.0f * viewScale), headerInner.withHeight (18.0f * viewScale), Justification::right); + } + + const auto ruleY = bodyBounds.getY() + headerHeight; + g.setStrokeColor (accent.withAlpha (0.16f)); + g.strokeLine ({ bodyBounds.getX(), ruleY }, { bodyBounds.getRight(), ruleY }); + + auto contentBounds = bodyBounds.reduced (10.0f * viewScale, 0.0f); + contentBounds = contentBounds.withY (ruleY + 4.0f * viewScale).withHeight (audioGraphNodeBaseContentHeight * viewScale); + node.paintNodeContent (g, contentBounds); + + auto parameterBounds = bodyBounds.reduced (10.0f * viewScale, 0.0f); + parameterBounds = parameterBounds.withY (ruleY + (audioGraphNodeBaseContentHeight + 5.0f) * viewScale); + + const auto parameterFont = font.withHeight (9.5f * viewScale); + for (int i = 0; i < node.getNumParameterRows(); ++i) + { + const auto info = node.getParameterInfo (i); + auto row = parameterBounds.removeFromTop (audioGraphNodeBaseParameterRowHeight * viewScale).reduced (0.0f, 2.0f * viewScale); + auto valueBox = row.removeFromRight (58.0f * viewScale); + + g.setFillColor (parameterBackgroundColor); + g.fillRoundedRect (row.reduced (0.0f, 1.0f * viewScale), 3.0f * viewScale); + + if (info.normalizedValue >= 0.0f) + { + const auto fillWidth = row.getWidth() * jlimit (0.0f, 1.0f, info.normalizedValue); + g.setFillColor (info.color.withAlpha (0.20f)); + g.fillRoundedRect (row.withWidth (fillWidth).reduced (0.0f, 1.0f * viewScale), 3.0f * viewScale); + } + + g.setFillColor (textColor); + g.fillFittedText (info.name, parameterFont, row.reduced (6.0f * viewScale, 0.0f), Justification::left); + + g.setFillColor (parameterValueBackgroundColor); + g.fillRoundedRect (valueBox.reduced (0.0f, 1.0f * viewScale), 3.0f * viewScale); + g.setFillColor (info.color); + g.fillFittedText (info.value, parameterFont, valueBox.reduced (5.0f * viewScale, 0.0f), Justification::right); + } + + const auto labelFont = font.withHeight (10.5f * viewScale); + + for (int i = 0; i < node.getNumInputPorts(); ++i) + paintAudioGraphPort (g, node, labelFont, node.getInputPortInfo (i), node.getInputPortCenter (i), viewScale, true); + + for (int i = 0; i < node.getNumOutputPorts(); ++i) + paintAudioGraphPort (g, node, labelFont, node.getOutputPortInfo (i), node.getOutputPortCenter (i), viewScale, false); +} + void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKeyboardComponent& keyboard) { auto bounds = keyboard.getLocalBounds(); @@ -956,7 +1216,7 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe // Draw keyboard background with subtle gradient shadow auto keyboardWidth = keyboard.getKeyStartRange().getEnd(); - auto shadowColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyShadowColorId); + auto shadowColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyShadowColorId).value_or (Color()); if (! shadowColor.isTransparent()) { @@ -970,7 +1230,7 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe } // Draw separator line at bottom - auto lineColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::keyOutlineColorId); + auto lineColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::keyOutlineColorId).value_or (Color()); if (! lineColor.isTransparent()) { g.setFillColor (lineColor); @@ -991,9 +1251,9 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe auto isOver = keyboard.isMouseOverNote (note); // Base colors from theme - auto whiteKeyColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyColorId); - auto pressedColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyPressedColorId); - auto outlineColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::keyOutlineColorId); + auto whiteKeyColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyColorId).value_or (Color()); + auto pressedColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::whiteKeyPressedColorId).value_or (Color()); + auto outlineColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::keyOutlineColorId).value_or (Color()); // Determine fill color based on state Color fillColor = whiteKeyColor; @@ -1084,8 +1344,8 @@ void paintMidiKeyboard (Graphics& g, const ApplicationTheme& theme, const MidiKe auto isOver = keyboard.isMouseOverNote (note); // Base colors from theme - auto blackKeyColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyColorId); - auto blackPressedColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyPressedColorId); + auto blackKeyColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyColorId).value_or (Color()); + auto blackPressedColor = ApplicationTheme::findColor (MidiKeyboardComponent::Style::blackKeyPressedColorId).value_or (Color()); // Determine fill color based on state Color fillColor = blackKeyColor; @@ -1529,6 +1789,20 @@ ApplicationTheme::Ptr createThemeVersion1() theme->setColor (KMeterComponent::Style::peakLevelColorId, Color (0xffffffff)); theme->setColor (KMeterComponent::Style::peakLevelClipColorId, Color (0xffff0000)); theme->setColor (KMeterComponent::Style::peakHoldColorId, Color (0xffffff00)); + + theme->setComponentStyle (ComponentStyle::createStyle (paintAudioGraphComponent)); + theme->setColor (AudioGraphComponent::Style::backgroundColorId, Color (0xff101522)); + theme->setColor (AudioGraphComponent::Style::gridColorId, Colors::white.withAlpha (0.045f)); + + theme->setComponentStyle (ComponentStyle::createStyle (paintAudioGraphNodeView)); + theme->setColor (AudioGraphNodeView::Style::shadowColorId, Colors::black); + theme->setColor (AudioGraphNodeView::Style::bodyBackgroundColorId, Color (0xff1e2535)); + theme->setColor (AudioGraphNodeView::Style::headerBackgroundColorId, Color (0xff141a26)); + theme->setColor (AudioGraphNodeView::Style::textColorId, Color (0xffd6d6d6)); + theme->setColor (AudioGraphNodeView::Style::subtitleTextColorId, Color (0xffb8b8b8)); + theme->setColor (AudioGraphNodeView::Style::parameterBackgroundColorId, Color (0xff263044)); + theme->setColor (AudioGraphNodeView::Style::parameterValueBackgroundColorId, Color (0xff1a2130)); + theme->setColor (AudioGraphNodeView::Style::portHoleColorId, Color (0xff101522)); #endif return theme; diff --git a/modules/yup_gui/themes/yup_ApplicationTheme.cpp b/modules/yup_gui/themes/yup_ApplicationTheme.cpp index 8a3c6d4fa..27519dd5a 100644 --- a/modules/yup_gui/themes/yup_ApplicationTheme.cpp +++ b/modules/yup_gui/themes/yup_ApplicationTheme.cpp @@ -35,7 +35,7 @@ void ApplicationTheme::setGlobalTheme (ApplicationTheme::Ptr s) getGlobalThemeInstance() = std::move (s); } -ApplicationTheme::ConstPtr ApplicationTheme::getGlobalTheme() +ApplicationTheme::Ptr ApplicationTheme::getGlobalTheme() { return getGlobalThemeInstance(); } @@ -48,10 +48,16 @@ ApplicationTheme::Ptr& ApplicationTheme::getGlobalThemeInstance() //============================================================================== -Color ApplicationTheme::findColor (const Identifier& colorId) +std::optional ApplicationTheme::findColor (const Identifier& colorId) { - auto it = getGlobalThemeInstance()->defaultColors.find (colorId); - return it != getGlobalThemeInstance()->defaultColors.end() ? it->second : Color(); + jassert (getGlobalThemeInstance() != nullptr); + + const auto& colors = getGlobalThemeInstance()->defaultColors; + + if (auto it = colors.find (colorId); it != colors.end()) + return it->second; + + return std::nullopt; } void ApplicationTheme::setColor (const Identifier& colorId, const Color& color) @@ -67,6 +73,25 @@ void ApplicationTheme::setColors (std::initializer_list ApplicationTheme::findMetric (const Identifier& metricId) +{ + jassert (getGlobalThemeInstance() != nullptr); + + const auto& metrics = getGlobalThemeInstance()->defaultMetrics; + + if (auto it = metrics.find (metricId); it != metrics.end()) + return it->second; + + return std::nullopt; +} + +void ApplicationTheme::setMetric (const Identifier& metricId, float value) +{ + defaultMetrics.insert_or_assign (metricId, value); +} + +//============================================================================== + void ApplicationTheme::setDefaultFont (Font font) { defaultFont = std::move (font); diff --git a/modules/yup_gui/themes/yup_ApplicationTheme.h b/modules/yup_gui/themes/yup_ApplicationTheme.h index a251b9c42..75605c6c7 100644 --- a/modules/yup_gui/themes/yup_ApplicationTheme.h +++ b/modules/yup_gui/themes/yup_ApplicationTheme.h @@ -65,7 +65,7 @@ class YUP_API ApplicationTheme final : public ReferenceCountedObject @returns A reference-counted pointer to the global ApplicationTheme. */ - static ApplicationTheme::ConstPtr getGlobalTheme(); + static ApplicationTheme::Ptr getGlobalTheme(); //============================================================================== /** @@ -119,15 +119,39 @@ class YUP_API ApplicationTheme final : public ReferenceCountedObject } //============================================================================== - /** */ - static Color findColor (const Identifier& colorId); + /** Returns a color from the global theme. + + @param colorId The identifier for the color to retrieve. + */ + static std::optional findColor (const Identifier& colorId); + + /** Sets a color in the global theme. - /** */ + @param colorId The identifier for the color to set. + @param color The color to set. + */ void setColor (const Identifier& colorId, const Color& color); - /** */ + /** Sets multiple colors in the global theme. + + @param colors An initializer list of color identifier and color pairs. + */ void setColors (std::initializer_list> colors); + //============================================================================== + /** Returns a named float metric from the global theme. + + Returns @p defaultValue when the metric has not been registered. + */ + static std::optional findMetric (const Identifier& metricId); + + /** Registers a named float metric in this theme. + + @param metricId The identifier for the metric to set. + @param value The value to set for the metric. + */ + void setMetric (const Identifier& metricId, float value); + //============================================================================== /** Sets the default text font for the application theme. @@ -164,6 +188,7 @@ class YUP_API ApplicationTheme final : public ReferenceCountedObject //============================================================================== std::unordered_map componentStyles; std::unordered_map defaultColors; + std::unordered_map defaultMetrics; Font defaultFont; Font defaultIconFont; diff --git a/modules/yup_gui/widgets/yup_ListBox.cpp b/modules/yup_gui/widgets/yup_ListBox.cpp index 9ccfb8656..9e285b490 100644 --- a/modules/yup_gui/widgets/yup_ListBox.cpp +++ b/modules/yup_gui/widgets/yup_ListBox.cpp @@ -420,6 +420,8 @@ void ListBox::updateContent() // Update scrolling calculations updateScrolling(); + updateScrollBars(); + updateScrolling(); // Layout visible rows layoutRows(); @@ -525,7 +527,13 @@ bool ListBox::isVariableWidthEnabled() const noexcept //============================================================================== void ListBox::setMinimumContentSize (int minSize) { - minimumContentSize = jmax (0, minSize); + auto newMinimumContentSize = jmax (0, minSize); + + if (minimumContentSize != newMinimumContentSize) + { + minimumContentSize = newMinimumContentSize; + updateContent(); + } } int ListBox::getMinimumContentSize() const noexcept @@ -563,11 +571,18 @@ Rectangle ListBox::getRowBounds (int rowIndex) const auto position = getRowPosition (rowIndex); auto size = getRowSize (rowIndex); + auto contentArea = getContentArea(); if (orientation == Orientation::vertical) - return Rectangle (0.0f, position - scrollOffset, getWidth(), size); + return Rectangle (contentArea.getX(), + contentArea.getY() + position - scrollOffset, + contentArea.getWidth(), + size); else - return Rectangle (position - scrollOffset, 0.0f, size, getHeight()); + return Rectangle (contentArea.getX() + position - scrollOffset, + contentArea.getY(), + size, + contentArea.getHeight()); } //============================================================================== @@ -637,8 +652,6 @@ void ListBox::mouseMove (const MouseEvent& event) void ListBox::mouseWheel (const MouseEvent& event, const MouseWheelData& wheelData) { - ignoreUnused (event); - if (needsScrolling()) { auto delta = (orientation == Orientation::vertical) @@ -648,6 +661,7 @@ void ListBox::mouseWheel (const MouseEvent& event, const MouseWheelData& wheelDa // Scroll by approximately 3 rows worth of content auto scrollAmount = delta * fixedRowHeight * 3.0f; scrollBy (-scrollAmount); + updateHoveredRow (event.getPosition()); } } @@ -837,6 +851,12 @@ void ListBox::layoutRows() visibleRowRange = Range (firstVisible >= 0 ? firstVisible : 0, lastVisible >= 0 ? lastVisible + 1 : 0); + for (auto& pair : rowComponents) + { + if (! visibleRowRange.contains (pair.first)) + pair.second->setVisible (false); + } + // Create or update visible rows for (int i = visibleRowRange.getStart(); i < visibleRowRange.getEnd(); ++i) { @@ -863,6 +883,12 @@ void ListBox::layoutRows() row->setVisible (true); } } + + if (verticalScrollBar != nullptr) + verticalScrollBar->toFront (false); + + if (horizontalScrollBar != nullptr) + horizontalScrollBar->toFront (false); } void ListBox::createOrUpdateRow (int rowIndex) @@ -918,13 +944,19 @@ void ListBox::removeUnusedRows() //============================================================================== void ListBox::scrollBy (float delta) { + auto oldScrollOffset = scrollOffset; + scrollOffset += delta; // Clamp scroll offset auto maxScroll = jmax (0.0f, totalContentSize - viewportSize); scrollOffset = jlimit (0.0f, maxScroll, scrollOffset); + if (oldScrollOffset == scrollOffset) + return; + layoutRows(); + updateScrollBars(); repaint(); } @@ -955,6 +987,7 @@ void ListBox::scrollToRow (int rowIndex) scrollOffset = jlimit (0.0f, maxScroll, scrollOffset); layoutRows(); + updateScrollBars(); repaint(); } @@ -1076,13 +1109,17 @@ int ListBox::getRowIndexAt (Point position) const if (model == nullptr) return -1; + auto contentArea = getContentArea(); + if (! contentArea.contains (position)) + return -1; + auto numRows = model->getNumRows(); if (numRows == 0) return -1; float searchPosition = (orientation == Orientation::vertical) - ? position.getY() + scrollOffset - : position.getX() + scrollOffset; + ? position.getY() - contentArea.getY() + scrollOffset + : position.getX() - contentArea.getX() + scrollOffset; float currentPosition = 0.0f; @@ -1186,7 +1223,26 @@ ScrollBar* ListBox::getHorizontalScrollBar() const noexcept void ListBox::updateScrollBars() { if (model == nullptr) + { + if (verticalScrollBar != nullptr) + verticalScrollBar->setVisible (false); + + if (horizontalScrollBar != nullptr) + horizontalScrollBar->setVisible (false); + return; + } + + if (orientation == Orientation::vertical) + { + if (horizontalScrollBar != nullptr) + horizontalScrollBar->setVisible (false); + } + else + { + if (verticalScrollBar != nullptr) + verticalScrollBar->setVisible (false); + } auto contentArea = getContentArea(); auto totalContent = getTotalContentSize(); @@ -1194,14 +1250,20 @@ void ListBox::updateScrollBars() if (orientation == Orientation::vertical) { // Update vertical scrollbar - verticalScrollBar->setRangeLimits (0.0, totalContent); - verticalScrollBar->setCurrentRange (scrollOffset, scrollOffset + contentArea.getHeight()); + if (verticalScrollBar != nullptr) + { + verticalScrollBar->setRangeLimits (0.0, totalContent); + verticalScrollBar->setCurrentRange (scrollOffset, scrollOffset + contentArea.getHeight()); + } } else { // Update horizontal scrollbar - horizontalScrollBar->setRangeLimits (0.0, totalContent); - horizontalScrollBar->setCurrentRange (scrollOffset, scrollOffset + contentArea.getWidth()); + if (horizontalScrollBar != nullptr) + { + horizontalScrollBar->setRangeLimits (0.0, totalContent); + horizontalScrollBar->setCurrentRange (scrollOffset, scrollOffset + contentArea.getWidth()); + } } } diff --git a/modules/yup_gui/yup_gui.h b/modules/yup_gui/yup_gui.h index 0945cc29e..9a2de2d1b 100644 --- a/modules/yup_gui/yup_gui.h +++ b/modules/yup_gui/yup_gui.h @@ -60,6 +60,15 @@ #define YUP_ENABLE_COMPONENT_REPAINT_DEBUGGING 0 #endif +//============================================================================== +/** Config: YUP_ENABLE_WINDOWING_EVENT_LOGGING + + Enable logging of windowing events like movement, resizes, mouse interactions. +*/ +#ifndef YUP_ENABLE_WINDOWING_EVENT_LOGGING +#define YUP_ENABLE_WINDOWING_EVENT_LOGGING 1 +#endif + //============================================================================== #include diff --git a/modules/yup_simd/yup_simd.h b/modules/yup_simd/yup_simd.h index f1e567a9d..9520b154e 100644 --- a/modules/yup_simd/yup_simd.h +++ b/modules/yup_simd/yup_simd.h @@ -65,7 +65,7 @@ //============================================================================== #ifndef YUP_USE_SSE_INTRINSICS -#if defined(__SSE__) +#if defined(__SSE__) || defined(_M_X64) || defined(_M_AMD64) || (defined(_M_IX86_FP) && _M_IX86_FP >= 2) #define YUP_USE_SSE_INTRINSICS 1 #endif #endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 685900b61..7f647a985 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -58,6 +58,8 @@ set (target_modules yup_audio_basics yup_audio_devices yup_audio_formats + yup_audio_processors + yup_audio_graph yup_dsp yup_events yup_data_model @@ -69,6 +71,10 @@ set (target_modules hmp3_library bungee_library) +if (YUP_PLATFORM_DESKTOP) + list (APPEND target_modules yup_audio_plugin_host) +endif() + if (NOT YUP_PLATFORM_EMSCRIPTEN) list (APPEND target_modules yup_gui yup_audio_gui) if (YUP_PLATFORM_DESKTOP AND NOT YUP_PLATFORM_WINDOWS) diff --git a/tests/yup_audio_graph.cpp b/tests/yup_audio_graph.cpp new file mode 100644 index 000000000..262daeec4 --- /dev/null +++ b/tests/yup_audio_graph.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_audio_graph/yup_AudioGraphProcessor.cpp" diff --git a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp new file mode 100644 index 000000000..f73116cc3 --- /dev/null +++ b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp @@ -0,0 +1,2665 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include +#include +#include + +#include + +using namespace yup; + +namespace +{ +AudioBusLayout stereoLayout() +{ + return AudioBusLayout ({ AudioBus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2) }, + { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) }); +} + +AudioBusLayout monoLayout() +{ + return AudioBusLayout ({ AudioBus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 1) }, + { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 1) }); +} + +AudioBusLayout midiLayout() +{ + return AudioBusLayout ({ AudioBus ("MIDI In", AudioBus::Type::MIDI, AudioBus::Direction::Input, 0) }, + { AudioBus ("MIDI Out", AudioBus::Type::MIDI, AudioBus::Direction::Output, 0) }); +} + +class TestProcessor : public AudioProcessor +{ +public: + explicit TestProcessor (float gainToUse = 1.0f, int latencyToUse = 0) + : AudioProcessor ("Test", stereoLayout()) + , gain (gainToUse) + , latency (latencyToUse) + { + } + + void prepareToPlay (float sampleRate, int maxBlockSize) override + { + preparedSampleRate = sampleRate; + preparedBlockSize = maxBlockSize; + prepared = true; + } + + void releaseResources() override { prepared = false; } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer&) override + { + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + audioBuffer.applyGain (channel, 0, audioBuffer.getNumSamples(), gain); + } + + int getLatencySamples() override { return latency; } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + + float preparedSampleRate = 0.0f; + int preparedBlockSize = 0; + bool prepared = false; + +private: + float gain = 1.0f; + int latency = 0; +}; + +class MidiPassthroughProcessor : public AudioProcessor +{ +public: + explicit MidiPassthroughProcessor (int latencyToUse = 0) + : AudioProcessor ("MIDI Passthrough", midiLayout()) + , latency (latencyToUse) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer&, MidiBuffer&) override {} + + int getLatencySamples() override { return latency; } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + +private: + int latency = 0; +}; + +class MidiDelayingProcessor : public AudioProcessor +{ +public: + explicit MidiDelayingProcessor (int latencyToUse) + : AudioProcessor ("MIDI Delay", midiLayout()) + , latency (latencyToUse) + { + pendingMidi.ensureSize (4096); + nextPendingMidi.ensureSize (4096); + } + + void prepareToPlay (float, int) override + { + pendingMidi.clear(); + nextPendingMidi.clear(); + } + + void releaseResources() override + { + pendingMidi.clear(); + nextPendingMidi.clear(); + } + + void flush() override + { + pendingMidi.clear(); + nextPendingMidi.clear(); + } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override + { + const int numSamples = audioBuffer.getNumSamples(); + const MidiBuffer inputMidi = midiBuffer; + + midiBuffer.clear(); + nextPendingMidi.clear(); + + for (const auto metadata : pendingMidi) + { + if (metadata.samplePosition < numSamples) + { + midiBuffer.addEvent (metadata.data, metadata.numBytes, metadata.samplePosition); + } + else + { + nextPendingMidi.addEvent (metadata.data, metadata.numBytes, metadata.samplePosition - numSamples); + } + } + + for (const auto metadata : inputMidi) + { + if (metadata.samplePosition < 0 || metadata.samplePosition >= numSamples) + { + continue; + } + + const int delayedPosition = metadata.samplePosition + latency; + + if (delayedPosition < numSamples) + { + midiBuffer.addEvent (metadata.data, metadata.numBytes, delayedPosition); + } + else + { + nextPendingMidi.addEvent (metadata.data, metadata.numBytes, delayedPosition - numSamples); + } + } + + pendingMidi.swapWith (nextPendingMidi); + } + + int getLatencySamples() override { return latency; } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + +private: + int latency = 0; + MidiBuffer pendingMidi; + MidiBuffer nextPendingMidi; +}; + +class MonoLayoutProcessor : public AudioProcessor +{ +public: + MonoLayoutProcessor() + : AudioProcessor ("Mono", monoLayout()) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer&, MidiBuffer&) override {} + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } +}; + +class DelayingProcessor : public TestProcessor +{ +public: + explicit DelayingProcessor (int latencyToUse) + : TestProcessor (1.0f, latencyToUse) + , delaySamples (latencyToUse) + , history (2, latencyToUse + 32) + { + history.clear(); + } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer&) override + { + const int ringSize = history.getNumSamples(); + + for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) + { + const int readPosition = (writePosition + ringSize - delaySamples) % ringSize; + + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + { + const float input = audioBuffer.getReadPointer (channel)[sample]; + audioBuffer.getWritePointer (channel)[sample] = history.getReadPointer (channel)[readPosition]; + history.getWritePointer (channel)[writePosition] = input; + } + + writePosition = (writePosition + 1) % ringSize; + } + } + +private: + int delaySamples = 0; + int writePosition = 0; + AudioBuffer history; +}; + +class BlockingLatencyProcessor : public TestProcessor +{ +public: + BlockingLatencyProcessor (std::atomic& enteredFlag, std::atomic& continueFlag) + : TestProcessor() + , entered (enteredFlag) + , shouldContinue (continueFlag) + { + } + + int getLatencySamples() override + { + entered.store (true); + + while (! shouldContinue.load()) + std::this_thread::yield(); + + return 0; + } + +private: + std::atomic& entered; + std::atomic& shouldContinue; +}; + +class StatefulGainProcessor : public AudioProcessor +{ +public: + explicit StatefulGainProcessor (float initialGain) + : AudioProcessor ("Stateful Gain", stereoLayout()) + , gain (initialGain) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer&) override + { + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + audioBuffer.applyGain (channel, 0, audioBuffer.getNumSamples(), gain); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock& memoryBlock) override + { + if (memoryBlock.getSize() != sizeof (float)) + return Result::fail ("Invalid gain state"); + + MemoryInputStream stream (memoryBlock, false); + gain = stream.readFloat(); + return Result::ok(); + } + + Result saveStateIntoMemory (MemoryBlock& memoryBlock) override + { + MemoryOutputStream stream (memoryBlock, false); + stream.writeFloat (gain); + stream.flush(); + return Result::ok(); + } + + bool hasEditor() const override { return false; } + + void setGain (float newGain) noexcept { gain = newGain; } + +private: + float gain = 1.0f; +}; + +AudioGraphNodeProperties statefulGainProperties (float positionX = 0.0f, float positionY = 0.0f) +{ + AudioGraphNodeProperties properties; + properties.identifier = "statefulGain"; + properties.name = "Stateful Gain"; + properties.positionX = positionX; + properties.positionY = positionY; + return properties; +} + +AudioGraphProcessor::NodeFactory statefulGainFactory() +{ + return [] (const AudioGraphNodeProperties& properties) -> ResultValue> + { + if (properties.identifier != "statefulGain") + return makeResultValueFail ("Unknown node type"); + + return makeResultValueOk (std::make_unique (1.0f)); + }; +} + +class StatefulExternalProcessor : public AudioProcessor +{ +public: + explicit StatefulExternalProcessor (String processorName, float initialGain = 1.0f) + : AudioProcessor (std::move (processorName), stereoLayout()) + , gain (initialGain) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer&) override + { + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + audioBuffer.applyGain (channel, 0, audioBuffer.getNumSamples(), gain); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock& memoryBlock) override + { + if (memoryBlock.getSize() != sizeof (float)) + return Result::fail ("Invalid external processor gain state"); + + MemoryInputStream stream (memoryBlock, false); + gain = stream.readFloat(); + return Result::ok(); + } + + Result saveStateIntoMemory (MemoryBlock& memoryBlock) override + { + MemoryOutputStream stream (memoryBlock, false); + stream.writeFloat (gain); + stream.flush(); + return Result::ok(); + } + + bool hasEditor() const override { return false; } + +private: + float gain = 1.0f; +}; + +class SaveFailingProcessor : public AudioProcessor +{ +public: + SaveFailingProcessor() + : AudioProcessor ("Save Failing", stereoLayout()) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer&, MidiBuffer&) override {} + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::fail ("Save failed"); } + + bool hasEditor() const override { return false; } +}; + +AudioGraphNodeProperties externalProcessorProperties (float positionX = 0.0f, float positionY = 0.0f) +{ + AudioGraphNodeProperties properties; + properties.identifier = "externalPlugin"; + properties.name = "External Plugin"; + properties.positionX = positionX; + properties.positionY = positionY; + + MemoryOutputStream stream (properties.creationData, false); + stream.writeString ("Fake Plugin"); + stream.writeString ("fake.plugin"); + stream.flush(); + + return properties; +} + +AudioGraphProcessor::NodeFactory statefulGainAndExternalFactory (int& externalLoadCount, String& externalIdentifier) +{ + return [&externalLoadCount, &externalIdentifier] (const AudioGraphNodeProperties& properties) -> ResultValue> + { + if (properties.identifier == "statefulGain") + return makeResultValueOk (std::make_unique (1.0f)); + + if (properties.identifier != "externalPlugin") + return makeResultValueFail ("Unknown node type"); + + MemoryInputStream stream (properties.creationData, false); + const auto pluginName = stream.readString(); + externalIdentifier = stream.readString(); + ++externalLoadCount; + + return makeResultValueOk (std::make_unique (pluginName)); + }; +} + +void fillImpulse (AudioBuffer& buffer) +{ + buffer.clear(); + buffer.getWritePointer (0)[0] = 1.0f; + buffer.getWritePointer (1)[0] = 1.0f; +} + +void fillImpulseAt (AudioBuffer& buffer, int samplePosition) +{ + buffer.clear(); + buffer.getWritePointer (0)[samplePosition] = 1.0f; + buffer.getWritePointer (1)[samplePosition] = 1.0f; +} + +int countMidiEventsAt (const MidiBuffer& midi, int samplePosition) +{ + int count = 0; + + for (const auto metadata : midi) + if (metadata.samplePosition == samplePosition) + ++count; + + return count; +} + +int countMidiEvents (const MidiBuffer& midi) +{ + int count = 0; + + for (const auto metadata : midi) + if (metadata.data != nullptr) + ++count; + + return count; +} + +std::unique_ptr parseMemoryBlockAsXml (const MemoryBlock& memoryBlock) +{ + MemoryInputStream stream (memoryBlock, false); + return parseXML (stream.readEntireStreamAsString()); +} + +MemoryBlock memoryBlockFromString (const String& text) +{ + MemoryBlock result; + MemoryOutputStream stream (result, false); + stream << text; + stream.flush(); + return result; +} + +MemoryBlock memoryBlockFromXml (const XmlElement& xml) +{ + MemoryBlock result; + MemoryOutputStream stream (result, false); + xml.writeTo (stream); + stream.flush(); + return result; +} + +String toBase64Text (const MemoryBlock& block) +{ + return Base64::toBase64 (block.getData(), block.getSize()); +} + +bool resetGraphToSingleGainPath (AudioGraphProcessor& graph, float gain) +{ + graph.clear(); + + const auto node = graph.addNode (std::make_unique (gain)); + if (! node.isValid()) + return false; + + if (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (node, 0) }) + .failed()) + return false; + + if (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), + AudioGraphEndpoint::graphOutput (0) }) + .failed()) + return false; + + return graph.commitChanges().wasOk(); +} + +bool isExpectedGain (float sample) +{ + return std::abs (sample - 0.25f) < 0.000001f + || std::abs (sample - 0.75f) < 0.000001f; +} + +class DenormalCheckProcessor : public AudioProcessor +{ +public: + DenormalCheckProcessor() + : AudioProcessor ("DenormalCheck", stereoLayout()) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer&) override + { + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + audioBuffer.applyGain (channel, 0, audioBuffer.getNumSamples(), 1.0f); + + denormalsWereDisabled = FloatVectorOperations::areDenormalsDisabled(); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + + bool denormalsWereDisabled = false; +}; + +AudioBusLayout mixedLayout() +{ + return AudioBusLayout ({ AudioBus ("Audio In", AudioBus::Type::Audio, AudioBus::Direction::Input, 2), + AudioBus ("MIDI In", AudioBus::Type::MIDI, AudioBus::Direction::Input, 0) }, + { AudioBus ("Audio Out", AudioBus::Type::Audio, AudioBus::Direction::Output, 2), + AudioBus ("MIDI Out", AudioBus::Type::MIDI, AudioBus::Direction::Output, 0) }); +} + +class MixedProcessor : public AudioProcessor +{ +public: + explicit MixedProcessor (float gainToUse = 1.0f, int latencyToUse = 0) + : AudioProcessor ("Mixed", mixedLayout()) + , gain (gainToUse) + , latency (latencyToUse) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer&) override + { + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + audioBuffer.applyGain (channel, 0, audioBuffer.getNumSamples(), gain); + } + + int getLatencySamples() override { return latency; } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + +private: + float gain = 1.0f; + int latency = 0; +}; + +class MixedDelayingProcessor : public AudioProcessor +{ +public: + explicit MixedDelayingProcessor (int latencyToUse) + : AudioProcessor ("MixedDelay", mixedLayout()) + , delaySamples (latencyToUse) + , history (2, latencyToUse + 32) + { + history.clear(); + } + + void prepareToPlay (float, int) override + { + history.clear(); + writePosition = 0; + } + + void releaseResources() override {} + + void flush() override + { + history.clear(); + writePosition = 0; + } + + void processBlock (AudioBuffer& audioBuffer, MidiBuffer&) override + { + const int ringSize = history.getNumSamples(); + + for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) + { + const int readPosition = (writePosition + ringSize - delaySamples) % ringSize; + + for (int channel = 0; channel < audioBuffer.getNumChannels(); ++channel) + { + const float input = audioBuffer.getReadPointer (channel)[sample]; + audioBuffer.getWritePointer (channel)[sample] = history.getReadPointer (channel)[readPosition]; + history.getWritePointer (channel)[writePosition] = input; + } + + writePosition = (writePosition + 1) % ringSize; + } + } + + int getLatencySamples() override { return delaySamples; } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + +private: + int delaySamples = 0; + int writePosition = 0; + AudioBuffer history; +}; +} // namespace + +TEST (AudioGraphProcessorTests, CommitPreparesNodes) +{ + AudioGraphProcessor graph; + auto processor = std::make_unique(); + auto* processorPtr = processor.get(); + const auto node = graph.addNode (std::move (processor)); + + EXPECT_TRUE (node.isValid()); + + graph.prepareToPlay (48000.0f, 128); + const auto result = graph.commitChanges(); + + EXPECT_TRUE (result.wasOk()); + EXPECT_TRUE (processorPtr->prepared); + EXPECT_FLOAT_EQ (48000.0f, processorPtr->preparedSampleRate); + EXPECT_EQ (128, processorPtr->preparedBlockSize); +} + +TEST (AudioGraphProcessorTests, RejectsMissingNodeConnection) +{ + AudioGraphProcessor graph; + + const auto result = graph.addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID::invalid(), 0), + AudioGraphEndpoint::graphOutput (0) }); + + EXPECT_TRUE (result.wasOk()); + EXPECT_TRUE (graph.commitChanges().failed()); +} + +TEST (AudioGraphProcessorTests, RejectsCycles) +{ + AudioGraphProcessor graph; + const auto first = graph.addNode (std::make_unique()); + const auto second = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), + AudioGraphEndpoint::nodeInput (second, 0) }) + .wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), + AudioGraphEndpoint::nodeInput (first, 0) }) + .wasOk()); + + EXPECT_TRUE (graph.commitChanges().failed()); +} + +TEST (AudioGraphProcessorTests, ProcessesSerialAudioChain) +{ + AudioGraphProcessor graph; + const auto first = graph.addNode (std::make_unique (2.0f)); + const auto second = graph.addNode (std::make_unique (0.5f)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (1.0f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (1.0f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, ProcessesBlocksLargerThanPreparedMaximumInChunks) +{ + AudioGraphProcessor graph; + graph.prepareToPlay (48000.0f, 16); + + const auto node = graph.addNode (std::make_unique (0.5f)); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 40); + MidiBuffer midi; + + for (int channel = 0; channel < audio.getNumChannels(); ++channel) + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + audio.getWritePointer (channel)[sample] = 1.0f; + + graph.processBlock (audio, midi); + + for (int channel = 0; channel < audio.getNumChannels(); ++channel) + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (channel)[sample]) << "channel " << channel << " sample " << sample; +} + +TEST (AudioGraphProcessorTests, PreservesMidiEventsInBlocksLargerThanPreparedMaximum) +{ + AudioGraphProcessor graph (midiLayout()); + graph.prepareToPlay (48000.0f, 16); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::graphOutput (0) }) + .wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (0, 40); + MidiBuffer midi; + const uint8 noteOn[] = { 0x90, 60, 100 }; + + midi.addEvent (noteOn, 3, 7); + midi.addEvent (noteOn, 3, 17); + midi.addEvent (noteOn, 3, 35); + + graph.processBlock (audio, midi); + + EXPECT_EQ (1, countMidiEventsAt (midi, 7)); + EXPECT_EQ (1, countMidiEventsAt (midi, 17)); + EXPECT_EQ (1, countMidiEventsAt (midi, 35)); + EXPECT_EQ (3, countMidiEvents (midi)); +} + +TEST (AudioGraphProcessorTests, MixesFanIn) +{ + AudioGraphProcessor graph; + const auto left = graph.addNode (std::make_unique (0.25f)); + const auto right = graph.addNode (std::make_unique (0.75f)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (left, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (right, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (left, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (right, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (1.0f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (1.0f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, PreservesMidiTimestamps) +{ + AudioBusLayout midiLayout ({ AudioBus ("MIDI In", AudioBus::Type::MIDI, AudioBus::Direction::Input, 0) }, + { AudioBus ("MIDI Out", AudioBus::Type::MIDI, AudioBus::Direction::Output, 0) }); + AudioGraphProcessor graph (midiLayout); + + const auto node = graph.addNode (std::make_unique()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (0, 32); + MidiBuffer midi; + const uint8 noteOn[] = { 0x90, 60, 100 }; + midi.addEvent (noteOn, 3, 7); + + graph.processBlock (audio, midi); + + EXPECT_EQ (1, countMidiEventsAt (midi, 7)); +} + +TEST (AudioGraphProcessorTests, CompensatesShorterParallelPaths) +{ + AudioGraphProcessor graph; + const auto dry = graph.addNode (std::make_unique (1.0f, 0)); + const auto latent = graph.addNode (std::make_unique (4)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latent, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (latent, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[4]); + EXPECT_EQ (4, graph.getLatencySamples()); + EXPECT_EQ (4, graph.getAllocationStats().totalCompensationSamples); +} + +TEST (AudioGraphProcessorTests, NullNodeIsRejected) +{ + AudioGraphProcessor graph; + + EXPECT_FALSE (graph.addNode (nullptr).isValid()); + EXPECT_TRUE (graph.hasUncommittedChanges()); +} + +TEST (AudioGraphProcessorTests, RejectsInvalidEndpointDirectionImmediately) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + + const auto badSource = graph.addConnection ({ AudioGraphEndpoint::nodeInput (node, 0), + AudioGraphEndpoint::graphOutput (0) }); + const auto badDestination = graph.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeOutput (node, 0) }); + + EXPECT_TRUE (badSource.failed()); + EXPECT_TRUE (badDestination.failed()); +} + +TEST (AudioGraphProcessorTests, RejectsDuplicateConnectionsImmediately) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + const AudioGraphConnection connection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (node, 0) }; + + EXPECT_TRUE (graph.addConnection (connection).wasOk()); + EXPECT_TRUE (graph.addConnection (connection).failed()); +} + +TEST (AudioGraphProcessorTests, RejectsInvalidBusIndexAtCommit) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (node, 99) }) + .wasOk()); + EXPECT_TRUE (graph.commitChanges().failed()); +} + +TEST (AudioGraphProcessorTests, RejectsAudioMidiTypeMismatchAtCommit) +{ + AudioGraphProcessor graph; + const auto audioNode = graph.addNode (std::make_unique()); + const auto midiNode = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (audioNode, 0), + AudioGraphEndpoint::nodeInput (midiNode, 0) }) + .wasOk()); + EXPECT_TRUE (graph.commitChanges().failed()); +} + +TEST (AudioGraphProcessorTests, RejectsAudioChannelMismatchAtCommit) +{ + AudioGraphProcessor graph; + const auto stereoNode = graph.addNode (std::make_unique()); + const auto monoNode = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (stereoNode, 0), + AudioGraphEndpoint::nodeInput (monoNode, 0) }) + .wasOk()); + EXPECT_TRUE (graph.commitChanges().failed()); +} + +TEST (AudioGraphProcessorTests, MissingCommitKeepsPreviousPlan) +{ + AudioGraphProcessor graph; + const auto first = graph.addNode (std::make_unique (0.5f)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + const auto second = graph.addNode (std::make_unique (0.25f)); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.hasUncommittedChanges()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); +} + +#if ! defined(YUP_WASM) +TEST (AudioGraphProcessorTests, CommitKeepsDirtyWhenModelChangesDuringCompilation) +{ + AudioGraphProcessor graph; + + std::atomic latencyEntered { false }; + std::atomic allowLatencyQueryToContinue { false }; + + const auto blockingNode = graph.addNode (std::make_unique (latencyEntered, allowLatencyQueryToContinue)); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (blockingNode, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (blockingNode, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + + std::atomic commitSucceeded { false }; + std::thread commitThread ([&] + { + commitSucceeded.store (graph.commitChanges().wasOk()); + }); + + for (int spinCount = 0; spinCount < 10000 && ! latencyEntered.load(); ++spinCount) + std::this_thread::yield(); + + const bool didEnterLatencyQuery = latencyEntered.load(); + EXPECT_TRUE (didEnterLatencyQuery); + + if (! didEnterLatencyQuery) + { + allowLatencyQueryToContinue.store (true); + commitThread.join(); + return; + } + + const auto addedDuringCommit = graph.addNode (std::make_unique()); + EXPECT_TRUE (addedDuringCommit.isValid()); + + allowLatencyQueryToContinue.store (true); + commitThread.join(); + + EXPECT_TRUE (commitSucceeded.load()); + EXPECT_TRUE (graph.hasUncommittedChanges()); + + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_FALSE (graph.hasUncommittedChanges()); +} +#endif + +TEST (AudioGraphProcessorTests, SaveAndLoadRestoresConnectionsAndNodeState) +{ + AudioGraphProcessor graph; + auto processor = std::make_unique (0.25f); + auto* processorPtr = processor.get(); + const auto node = graph.addNode (std::move (processor), statefulGainProperties (12.0f, 34.0f)); + + const AudioGraphConnection inputConnection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (node, 0) }; + const AudioGraphConnection outputConnection { AudioGraphEndpoint::nodeOutput (node, 0), + AudioGraphEndpoint::graphOutput (0) }; + const AudioGraphConnection bypassConnection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::graphOutput (0) }; + + ASSERT_TRUE (graph.addConnection (inputConnection).wasOk()); + ASSERT_TRUE (graph.addConnection (outputConnection).wasOk()); + ASSERT_TRUE (graph.commitChanges().wasOk()); + + MemoryBlock savedState; + EXPECT_TRUE (graph.saveStateIntoMemory (savedState).wasOk()); + + auto xml = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, xml.get()); + EXPECT_TRUE (xml->hasTagName ("YUPAudioGraphState")); + + auto* savedNodeElement = xml->getChildByName ("nodes")->getChildByName ("node"); + ASSERT_NE (nullptr, savedNodeElement); + auto* savedNodeStateElement = savedNodeElement->getChildByName ("state"); + ASSERT_NE (nullptr, savedNodeStateElement); + EXPECT_EQ (String ("base64"), savedNodeStateElement->getStringAttribute ("encoding")); + EXPECT_FALSE (savedNodeStateElement->getAllSubText().trim().isEmpty()); + + processorPtr->setGain (0.75f); + EXPECT_TRUE (graph.removeConnection (inputConnection)); + EXPECT_TRUE (graph.removeConnection (outputConnection)); + EXPECT_TRUE (graph.addConnection (bypassConnection).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + graph.setNodeFactory (statefulGainFactory()); + EXPECT_TRUE (graph.loadStateFromMemory (savedState).wasOk()); + EXPECT_FALSE (graph.hasUncommittedChanges()); + + const auto properties = graph.getNodeProperties (node); + ASSERT_TRUE (properties.has_value()); + EXPECT_EQ (String ("statefulGain"), properties->identifier); + EXPECT_FLOAT_EQ (12.0f, properties->positionX); + EXPECT_FLOAT_EQ (34.0f, properties->positionY); + + const auto connections = graph.getConnections(); + ASSERT_EQ (2u, connections.size()); + EXPECT_EQ (inputConnection, connections[0]); + EXPECT_EQ (outputConnection, connections[1]); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, LoadStateRecreatesProcessorNodesWithFactory) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique (0.25f), + statefulGainProperties (64.0f, 128.0f)); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + AudioGraphProcessor destination; + destination.setNodeFactory (statefulGainFactory()); + + EXPECT_TRUE (destination.loadStateFromMemory (savedState).wasOk()); + + const auto properties = destination.getNodeProperties (node); + ASSERT_TRUE (properties.has_value()); + EXPECT_EQ (String ("statefulGain"), properties->identifier); + EXPECT_FLOAT_EQ (64.0f, properties->positionX); + EXPECT_FLOAT_EQ (128.0f, properties->positionY); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + destination.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, CreateXmlAndRestoreFromXmlCanBeUsedDirectly) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique (0.25f), + statefulGainProperties (96.0f, 192.0f)); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + const auto xml = source.createXml(); + ASSERT_NE (nullptr, xml.get()); + EXPECT_TRUE (xml->hasTagName ("YUPAudioGraphState")); + + AudioGraphProcessor destination; + destination.setNodeFactory (statefulGainFactory()); + + ASSERT_TRUE (destination.restoreFromXml (*xml).wasOk()); + EXPECT_FALSE (destination.hasUncommittedChanges()); + + const auto properties = destination.getNodeProperties (node); + ASSERT_TRUE (properties.has_value()); + EXPECT_EQ (String ("statefulGain"), properties->identifier); + EXPECT_FLOAT_EQ (96.0f, properties->positionX); + EXPECT_FLOAT_EQ (192.0f, properties->positionY); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + destination.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, LoadStateFailsWhenFactoryIsMissing) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique (0.25f), + statefulGainProperties()); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + AudioGraphProcessor destination; + EXPECT_TRUE (destination.loadStateFromMemory (savedState).failed()); +} + +TEST (AudioGraphProcessorTests, LoadStateRecreatesExternalNodesWithOpaqueCreationData) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique ("Fake Plugin", 0.25f), + externalProcessorProperties (4.0f, 8.0f)); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + auto xml = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, xml.get()); + auto* savedNodeElement = xml->getChildByName ("nodes")->getChildByName ("node"); + ASSERT_NE (nullptr, savedNodeElement); + auto* creationDataElement = savedNodeElement->getChildByName ("creationData"); + ASSERT_NE (nullptr, creationDataElement); + EXPECT_EQ (String ("base64"), creationDataElement->getStringAttribute ("encoding")); + EXPECT_FALSE (creationDataElement->getAllSubText().trim().isEmpty()); + + int externalLoadCount = 0; + String externalIdentifier; + + AudioGraphProcessor destination; + destination.setNodeFactory (statefulGainAndExternalFactory (externalLoadCount, externalIdentifier)); + + EXPECT_TRUE (destination.loadStateFromMemory (savedState).wasOk()); + EXPECT_EQ (1, externalLoadCount); + EXPECT_EQ (String ("fake.plugin"), externalIdentifier); + + const auto properties = destination.getNodeProperties (node); + ASSERT_TRUE (properties.has_value()); + EXPECT_EQ (String ("externalPlugin"), properties->identifier); + EXPECT_FALSE (properties->creationData.isEmpty()); + EXPECT_FLOAT_EQ (4.0f, properties->positionX); + EXPECT_FLOAT_EQ (8.0f, properties->positionY); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + destination.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, LoadStateFailureRestoresPreviousGraphModel) +{ + AudioGraphProcessor invalidSource; + ASSERT_TRUE (invalidSource.addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID (999), 0), + AudioGraphEndpoint::graphOutput (0) }) + .wasOk()); + + MemoryBlock invalidState; + ASSERT_TRUE (invalidSource.saveStateIntoMemory (invalidState).wasOk()); + + AudioGraphProcessor destination; + ASSERT_TRUE (resetGraphToSingleGainPath (destination, 0.5f)); + EXPECT_FALSE (destination.hasUncommittedChanges()); + + EXPECT_TRUE (destination.loadStateFromMemory (invalidState).failed()); + EXPECT_FALSE (destination.hasUncommittedChanges()); + + const auto connections = destination.getConnections(); + ASSERT_EQ (2u, connections.size()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + destination.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, SaveStateWritesCompleteXmlTopology) +{ + AudioGraphProcessor graph; + const auto gainNode = graph.addNode (std::make_unique (0.5f), + statefulGainProperties (11.0f, 22.0f)); + const auto externalNode = graph.addNode (std::make_unique ("External", 0.25f), + externalProcessorProperties (33.0f, 44.0f)); + + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (gainNode, 0) }).wasOk()); + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (gainNode, 0), AudioGraphEndpoint::nodeInput (externalNode, 0) }).wasOk()); + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (externalNode, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (graph.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (graph.saveStateIntoMemory (savedState).wasOk()); + + const auto xml = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, xml.get()); + EXPECT_TRUE (xml->hasTagName ("YUPAudioGraphState")); + EXPECT_EQ (1, xml->getIntAttribute ("version")); + EXPECT_EQ (String (static_cast (externalNode.getRawID())), xml->getStringAttribute ("nextNodeID")); + + auto* nodesElement = xml->getChildByName ("nodes"); + ASSERT_NE (nullptr, nodesElement); + EXPECT_EQ (2, nodesElement->getNumChildElements()); + + auto* gainElement = nodesElement->getChildByAttribute ("id", String (static_cast (gainNode.getRawID()))); + ASSERT_NE (nullptr, gainElement); + EXPECT_EQ (String ("statefulGain"), gainElement->getStringAttribute ("identifier")); + EXPECT_EQ (String ("Stateful Gain"), gainElement->getStringAttribute ("name")); + EXPECT_DOUBLE_EQ (11.0, gainElement->getDoubleAttribute ("positionX")); + EXPECT_DOUBLE_EQ (22.0, gainElement->getDoubleAttribute ("positionY")); + ASSERT_NE (nullptr, gainElement->getChildByName ("state")); + ASSERT_NE (nullptr, gainElement->getChildByName ("creationData")); + + auto* externalElement = nodesElement->getChildByAttribute ("id", String (static_cast (externalNode.getRawID()))); + ASSERT_NE (nullptr, externalElement); + EXPECT_EQ (String ("externalPlugin"), externalElement->getStringAttribute ("identifier")); + EXPECT_EQ (String ("External Plugin"), externalElement->getStringAttribute ("name")); + EXPECT_DOUBLE_EQ (33.0, externalElement->getDoubleAttribute ("positionX")); + EXPECT_DOUBLE_EQ (44.0, externalElement->getDoubleAttribute ("positionY")); + EXPECT_FALSE (externalElement->getChildByName ("creationData")->getAllSubText().trim().isEmpty()); + + auto* connectionsElement = xml->getChildByName ("connections"); + ASSERT_NE (nullptr, connectionsElement); + EXPECT_EQ (4, connectionsElement->getNumChildElements()); + + auto* firstConnection = connectionsElement->getChildByName ("connection"); + ASSERT_NE (nullptr, firstConnection); + ASSERT_NE (nullptr, firstConnection->getChildByName ("source")); + ASSERT_NE (nullptr, firstConnection->getChildByName ("destination")); + EXPECT_EQ (String ("graphInput"), firstConnection->getChildByName ("source")->getStringAttribute ("kind")); + EXPECT_EQ (String ("nodeInput"), firstConnection->getChildByName ("destination")->getStringAttribute ("kind")); +} + +TEST (AudioGraphProcessorTests, LoadStateRestoresMultiNodeXmlGraphAndNextNodeID) +{ + AudioGraphProcessor source; + const auto firstNode = source.addNode (std::make_unique (0.5f), + statefulGainProperties (10.0f, 20.0f)); + const auto secondNode = source.addNode (std::make_unique (0.25f), + statefulGainProperties (30.0f, 40.0f)); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (firstNode, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (firstNode, 0), AudioGraphEndpoint::nodeInput (secondNode, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (secondNode, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + AudioGraphProcessor destination; + destination.setNodeFactory (statefulGainFactory()); + + ASSERT_TRUE (destination.loadStateFromMemory (savedState).wasOk()); + EXPECT_FALSE (destination.hasUncommittedChanges()); + + const auto firstProperties = destination.getNodeProperties (firstNode); + ASSERT_TRUE (firstProperties.has_value()); + EXPECT_FLOAT_EQ (10.0f, firstProperties->positionX); + EXPECT_FLOAT_EQ (20.0f, firstProperties->positionY); + + const auto secondProperties = destination.getNodeProperties (secondNode); + ASSERT_TRUE (secondProperties.has_value()); + EXPECT_FLOAT_EQ (30.0f, secondProperties->positionX); + EXPECT_FLOAT_EQ (40.0f, secondProperties->positionY); + + const auto connections = destination.getConnections(); + ASSERT_EQ (3u, connections.size()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + destination.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.125f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.125f, audio.getReadPointer (1)[0]); + + const auto nextNode = destination.addNode (std::make_unique()); + EXPECT_GT (nextNode.getRawID(), secondNode.getRawID()); +} + +TEST (AudioGraphProcessorTests, SaveStateFailsWhenNodeStateSaveFails) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + + MemoryBlock savedState; + EXPECT_TRUE (graph.saveStateIntoMemory (savedState).failed()); +} + +TEST (AudioGraphProcessorTests, CreateXmlReturnsNullWhenNodeStateSaveFails) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + + auto xml = graph.createXml(); + EXPECT_EQ (nullptr, xml.get()); +} + +TEST (AudioGraphProcessorTests, LoadStateRejectsMalformedXmlHeaderAndUnsupportedVersion) +{ + AudioGraphProcessor graph; + const String unsupportedVersionXml = "" + "" + "" + ""; + + EXPECT_TRUE (graph.loadStateFromMemory (memoryBlockFromString ("not xml")).failed()); + EXPECT_TRUE (graph.loadStateFromMemory (memoryBlockFromString ("")).failed()); + EXPECT_TRUE (graph.loadStateFromMemory (memoryBlockFromString (unsupportedVersionXml)).failed()); +} + +TEST (AudioGraphProcessorTests, LoadStateRejectsMissingRequiredXmlSections) +{ + AudioGraphProcessor graph; + const String missingNodesXml = "" + "" + ""; + const String missingConnectionsXml = "" + "" + ""; + + EXPECT_TRUE (graph.loadStateFromMemory (memoryBlockFromString (missingNodesXml)).failed()); + EXPECT_TRUE (graph.loadStateFromMemory (memoryBlockFromString (missingConnectionsXml)).failed()); +} + +TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidBase64Payloads) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique ("External", 0.25f), + externalProcessorProperties()); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + auto invalidNodeState = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, invalidNodeState.get()); + auto* stateElement = invalidNodeState->getChildByName ("nodes")->getChildByName ("node")->getChildByName ("state"); + ASSERT_NE (nullptr, stateElement); + stateElement->deleteAllChildElements(); + stateElement->addTextElement ("not-base64"); + + int externalLoadCount = 0; + String externalIdentifier; + + AudioGraphProcessor destination; + destination.setNodeFactory (statefulGainAndExternalFactory (externalLoadCount, externalIdentifier)); + EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*invalidNodeState)).failed()); + EXPECT_EQ (0, externalLoadCount); + + auto invalidCreationData = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, invalidCreationData.get()); + auto* creationDataElement = invalidCreationData->getChildByName ("nodes")->getChildByName ("node")->getChildByName ("creationData"); + ASSERT_NE (nullptr, creationDataElement); + creationDataElement->deleteAllChildElements(); + creationDataElement->addTextElement ("not-base64"); + + EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*invalidCreationData)).failed()); + EXPECT_EQ (0, externalLoadCount); +} + +TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidConnectionXml) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique (0.5f), + statefulGainProperties()); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + auto invalidKind = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, invalidKind.get()); + auto* firstConnection = invalidKind->getChildByName ("connections")->getChildByName ("connection"); + ASSERT_NE (nullptr, firstConnection); + firstConnection->getChildByName ("source")->setAttribute ("kind", "invalidKind"); + + AudioGraphProcessor destination; + destination.setNodeFactory (statefulGainFactory()); + EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*invalidKind)).failed()); + + auto missingDestination = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, missingDestination.get()); + firstConnection = missingDestination->getChildByName ("connections")->getChildByName ("connection"); + ASSERT_NE (nullptr, firstConnection); + firstConnection->removeChildElement (firstConnection->getChildByName ("destination"), true); + + EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*missingDestination)).failed()); +} + +TEST (AudioGraphProcessorTests, LoadStateFailsWhenFactoryReturnsNullProcessor) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique (0.25f), + statefulGainProperties()); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + AudioGraphProcessor destination; + destination.setNodeFactory ([] (const AudioGraphNodeProperties&) -> ResultValue> + { + return makeResultValueOk (std::unique_ptr()); + }); + + EXPECT_TRUE (destination.loadStateFromMemory (savedState).failed()); +} + +TEST (AudioGraphProcessorTests, LoadStateFailureFromNodeStateCallbackRestoresPreviousGraph) +{ + AudioGraphProcessor source; + const auto node = source.addNode (std::make_unique (0.25f), + statefulGainProperties()); + + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (source.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (source.commitChanges().wasOk()); + + MemoryBlock savedState; + ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); + + auto xml = parseMemoryBlockAsXml (savedState); + ASSERT_NE (nullptr, xml.get()); + auto* stateElement = xml->getChildByName ("nodes")->getChildByName ("node")->getChildByName ("state"); + ASSERT_NE (nullptr, stateElement); + stateElement->deleteAllChildElements(); + + const MemoryBlock invalidState ("bad", 3); + stateElement->addTextElement (toBase64Text (invalidState)); + + AudioGraphProcessor destination; + destination.setNodeFactory (statefulGainFactory()); + ASSERT_TRUE (resetGraphToSingleGainPath (destination, 0.5f)); + EXPECT_FALSE (destination.hasUncommittedChanges()); + + EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*xml)).failed()); + EXPECT_FALSE (destination.hasUncommittedChanges()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + destination.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (1)[0]); +} + +TEST (AudioGraphProcessorTests, RemoveConnectionStopsRoutingAfterCommit) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + const AudioGraphConnection inputConnection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (node, 0) }; + const AudioGraphConnection outputConnection { AudioGraphEndpoint::nodeOutput (node, 0), + AudioGraphEndpoint::graphOutput (0) }; + + EXPECT_TRUE (graph.addConnection (inputConnection).wasOk()); + EXPECT_TRUE (graph.addConnection (outputConnection).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_TRUE (graph.removeConnection (outputConnection)); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + EXPECT_FALSE (graph.removeConnection (outputConnection)); +} + +TEST (AudioGraphProcessorTests, RemoveNodePrunesConnections) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.removeNode (node)); + EXPECT_EQ (nullptr, graph.getNodeProcessor (node)); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + EXPECT_FALSE (graph.removeNode (node)); +} + +TEST (AudioGraphProcessorTests, ClearRemovesAllRouting) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + graph.clear(); + EXPECT_TRUE (graph.hasUncommittedChanges()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_FALSE (graph.hasUncommittedChanges()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); +} + +TEST (AudioGraphProcessorTests, ReleaseResourcesMarksPreparedNodesUnprepared) +{ + AudioGraphProcessor graph; + auto processor = std::make_unique(); + auto* processorPtr = processor.get(); + const auto node = graph.addNode (std::move (processor)); + + EXPECT_TRUE (node.isValid()); + graph.prepareToPlay (44100.0f, 64); + EXPECT_TRUE (processorPtr->prepared); + + graph.releaseResources(); + + EXPECT_FALSE (processorPtr->prepared); +} + +TEST (AudioGraphProcessorTests, ReportsAllocationStats) +{ + AudioGraphProcessor graph; + const auto first = graph.addNode (std::make_unique()); + const auto second = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + const auto stats = graph.getAllocationStats(); + + EXPECT_EQ (2, stats.scratchAudioBuffers); + EXPECT_EQ (2, stats.midiBuffers); + EXPECT_GE (stats.maxPreallocatedChannels, 2); + EXPECT_GE (stats.maxPreallocatedBlockSize, 1); +} + +TEST (AudioGraphProcessorTests, WorkerThreadOutputMatchesSingleThreadOutput) +{ + AudioGraphProcessor singleThreaded; + const auto singleLeft = singleThreaded.addNode (std::make_unique (0.25f)); + const auto singleRight = singleThreaded.addNode (std::make_unique (0.75f)); + + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (singleLeft, 0) }).wasOk()); + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (singleRight, 0) }).wasOk()); + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::nodeOutput (singleLeft, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::nodeOutput (singleRight, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (singleThreaded.commitChanges().wasOk()); + + AudioGraphProcessor threaded; + threaded.setNumWorkerThreads (2); + const auto threadedLeft = threaded.addNode (std::make_unique (0.25f)); + const auto threadedRight = threaded.addNode (std::make_unique (0.75f)); + + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (threadedLeft, 0) }).wasOk()); + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (threadedRight, 0) }).wasOk()); + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::nodeOutput (threadedLeft, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::nodeOutput (threadedRight, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (threaded.commitChanges().wasOk()); + + AudioBuffer singleAudio (2, 16); + AudioBuffer threadedAudio (2, 16); + MidiBuffer singleMidi; + MidiBuffer threadedMidi; + fillImpulse (singleAudio); + fillImpulse (threadedAudio); + + singleThreaded.processBlock (singleAudio, singleMidi); + threaded.processBlock (threadedAudio, threadedMidi); + + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < 16; ++sample) + { + EXPECT_FLOAT_EQ (singleAudio.getReadPointer (channel)[sample], + threadedAudio.getReadPointer (channel)[sample]); + } + } +} + +TEST (AudioGraphProcessorTests, WorkerThreadsMatchSingleThreadOutputUnderLoad) +{ + constexpr int numBranches = 16; + constexpr int numSamples = 64; + constexpr int numBlocks = 128; + + AudioGraphProcessor singleThreaded; + AudioGraphProcessor threaded; + threaded.setNumWorkerThreads (4); + + for (int branch = 0; branch < numBranches; ++branch) + { + const float gain = 1.0f / static_cast (numBranches); + const auto singleNode = singleThreaded.addNode (std::make_unique (gain)); + const auto threadedNode = threaded.addNode (std::make_unique (gain)); + + EXPECT_TRUE (singleNode.isValid()); + EXPECT_TRUE (threadedNode.isValid()); + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (singleNode, 0) }) + .wasOk()); + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::nodeOutput (singleNode, 0), + AudioGraphEndpoint::graphOutput (0) }) + .wasOk()); + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (threadedNode, 0) }) + .wasOk()); + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::nodeOutput (threadedNode, 0), + AudioGraphEndpoint::graphOutput (0) }) + .wasOk()); + } + + EXPECT_TRUE (singleThreaded.commitChanges().wasOk()); + EXPECT_TRUE (threaded.commitChanges().wasOk()); + + AudioBuffer singleAudio (2, numSamples); + AudioBuffer threadedAudio (2, numSamples); + MidiBuffer singleMidi; + MidiBuffer threadedMidi; + + for (int block = 0; block < numBlocks; ++block) + { + singleAudio.clear(); + threadedAudio.clear(); + singleMidi.clear(); + threadedMidi.clear(); + + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < numSamples; ++sample) + { + const float input = static_cast ((block + 1) * (sample + 1)) * 0.0001f; + singleAudio.getWritePointer (channel)[sample] = input; + threadedAudio.getWritePointer (channel)[sample] = input; + } + } + + singleThreaded.processBlock (singleAudio, singleMidi); + threaded.processBlock (threadedAudio, threadedMidi); + + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < numSamples; ++sample) + { + EXPECT_FLOAT_EQ (singleAudio.getReadPointer (channel)[sample], + threadedAudio.getReadPointer (channel)[sample]); + } + } + } +} + +TEST (AudioGraphProcessorTests, SwitchingWorkerThreadsBetweenBlocksPreservesOutput) +{ + constexpr int numBranches = 8; + constexpr int numSamples = 48; + constexpr int numBlocks = 64; + constexpr int threadCounts[] = { 0, 1, 4, 2, 0, 3 }; + constexpr int numThreadCounts = sizeof (threadCounts) / sizeof (threadCounts[0]); + + AudioGraphProcessor singleThreaded; + AudioGraphProcessor threaded; + + for (int branch = 0; branch < numBranches; ++branch) + { + const float gain = 1.0f / static_cast (numBranches); + const auto singleNode = singleThreaded.addNode (std::make_unique (gain)); + const auto threadedNode = threaded.addNode (std::make_unique (gain)); + + EXPECT_TRUE (singleNode.isValid()); + EXPECT_TRUE (threadedNode.isValid()); + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (singleNode, 0) }) + .wasOk()); + EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::nodeOutput (singleNode, 0), + AudioGraphEndpoint::graphOutput (0) }) + .wasOk()); + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (threadedNode, 0) }) + .wasOk()); + EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::nodeOutput (threadedNode, 0), + AudioGraphEndpoint::graphOutput (0) }) + .wasOk()); + } + + EXPECT_TRUE (singleThreaded.commitChanges().wasOk()); + EXPECT_TRUE (threaded.commitChanges().wasOk()); + + AudioBuffer singleAudio (2, numSamples); + AudioBuffer threadedAudio (2, numSamples); + MidiBuffer singleMidi; + MidiBuffer threadedMidi; + + for (int block = 0; block < numBlocks; ++block) + { + threaded.setNumWorkerThreads (threadCounts[static_cast (block) % numThreadCounts]); + + singleAudio.clear(); + threadedAudio.clear(); + singleMidi.clear(); + threadedMidi.clear(); + + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < numSamples; ++sample) + { + const float input = static_cast ((block + channel + 1) * (sample + 3)) * 0.0002f; + singleAudio.getWritePointer (channel)[sample] = input; + threadedAudio.getWritePointer (channel)[sample] = input; + } + } + + singleThreaded.processBlock (singleAudio, singleMidi); + threaded.processBlock (threadedAudio, threadedMidi); + + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < numSamples; ++sample) + { + EXPECT_FLOAT_EQ (singleAudio.getReadPointer (channel)[sample], + threadedAudio.getReadPointer (channel)[sample]); + } + } + } +} + +#if ! defined(YUP_WASM) +TEST (AudioGraphProcessorTests, ConcurrentCommitsDoNotInvalidateAudioThreadPlan) +{ + AudioGraphProcessor graph; + graph.setNumWorkerThreads (2); + graph.prepareToPlay (48000.0f, 32); + + ASSERT_TRUE (resetGraphToSingleGainPath (graph, 0.25f)); + + std::atomic keepProcessing { true }; + std::atomic startProcessing { false }; + std::atomic invalidBlocks { 0 }; + std::atomic commitFailures { 0 }; + std::atomic processedBlocks { 0 }; + + std::thread audioThread ([&] + { + AudioBuffer audio (2, 32); + MidiBuffer midi; + + while (! startProcessing.load()) + std::this_thread::yield(); + + while (keepProcessing.load()) + { + audio.clear(); + midi.clear(); + + for (int channel = 0; channel < audio.getNumChannels(); ++channel) + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + audio.getWritePointer (channel)[sample] = 1.0f; + + graph.processBlock (audio, midi); + + for (int channel = 0; channel < audio.getNumChannels(); ++channel) + { + for (int sample = 0; sample < audio.getNumSamples(); ++sample) + { + if (! isExpectedGain (audio.getReadPointer (channel)[sample])) + { + ++invalidBlocks; + break; + } + } + } + + ++processedBlocks; + std::this_thread::yield(); + } + }); + + std::thread controlThread ([&] + { + startProcessing.store (true); + + for (int waitCount = 0; waitCount < 10000 && processedBlocks.load() == 0; ++waitCount) + std::this_thread::yield(); + + for (int iteration = 0; iteration < 250; ++iteration) + { + const float gain = (iteration % 2) == 0 ? 0.75f : 0.25f; + + if (! resetGraphToSingleGainPath (graph, gain)) + ++commitFailures; + + std::this_thread::yield(); + } + + keepProcessing.store (false); + }); + + controlThread.join(); + audioThread.join(); + + EXPECT_EQ (0, commitFailures.load()); + EXPECT_EQ (0, invalidBlocks.load()); + EXPECT_GT (processedBlocks.load(), 0); +} +#endif + +TEST (AudioGraphProcessorTests, MidiCompensationCanSpillIntoNextBlock) +{ + AudioBusLayout layout = midiLayout(); + AudioGraphProcessor graph (layout); + const auto latent = graph.addNode (std::make_unique (6)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latent, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (latent, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (0, 8); + MidiBuffer midi; + const uint8 noteOn[] = { 0x90, 64, 100 }; + midi.addEvent (noteOn, 3, 5); + + graph.processBlock (audio, midi); + + EXPECT_EQ (1, countMidiEventsAt (midi, 5)); + EXPECT_EQ (1, countMidiEvents (midi)); + + midi.clear(); + graph.processBlock (audio, midi); + + EXPECT_EQ (1, countMidiEventsAt (midi, 3)); +} + +TEST (AudioGraphProcessorTests, PdcAccumulatesSerialPathLatencyAtGraphOutput) +{ + AudioGraphProcessor graph; + const auto dry = graph.addNode (std::make_unique()); + const auto firstDelay = graph.addNode (std::make_unique (3)); + const auto secondDelay = graph.addNode (std::make_unique (4)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (firstDelay, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (firstDelay, 0), AudioGraphEndpoint::nodeInput (secondDelay, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (secondDelay, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[7]); + EXPECT_EQ (7, graph.getLatencySamples()); + EXPECT_EQ (7, graph.getAllocationStats().totalCompensationSamples); + EXPECT_EQ (1, graph.getAllocationStats().delayLines); +} + +TEST (AudioGraphProcessorTests, PdcCompensatesFanInAtProcessorInput) +{ + AudioGraphProcessor graph; + const auto dry = graph.addNode (std::make_unique()); + const auto delayed = graph.addNode (std::make_unique (5)); + const auto summer = graph.addNode (std::make_unique()); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::nodeInput (summer, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::nodeInput (summer, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (summer, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[5]); + EXPECT_EQ (5, graph.getLatencySamples()); + EXPECT_EQ (1, graph.getAllocationStats().delayLines); +} + +TEST (AudioGraphProcessorTests, PdcDelaysDirectAudioOutputToMatchLatentAudioPath) +{ + AudioGraphProcessor graph; + const auto delayed = graph.addNode (std::make_unique (5)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulseAt (audio, 3); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[3]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[8]); + EXPECT_EQ (5, graph.getLatencySamples()); + EXPECT_EQ (1, graph.getAllocationStats().delayLines); +} + +TEST (AudioGraphProcessorTests, PdcAudioDelayLongerThanBlockSpillsAcrossBlocks) +{ + AudioGraphProcessor graph; + const auto dry = graph.addNode (std::make_unique()); + const auto delayed = graph.addNode (std::make_unique (10)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 4); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + + audio.clear(); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + + audio.clear(); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[1]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[2]); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[3]); +} + +TEST (AudioGraphProcessorTests, PdcDirectAudioOutputCompensationSpillsAcrossBlocks) +{ + AudioGraphProcessor graph; + const auto delayed = graph.addNode (std::make_unique (6)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 4); + MidiBuffer midi; + fillImpulseAt (audio, 1); + + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[1]); + + audio.clear(); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[2]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[3]); +} + +TEST (AudioGraphProcessorTests, PdcFlushClearsPendingAudioCompensation) +{ + AudioGraphProcessor graph; + const auto latencyReporter = graph.addNode (std::make_unique (0.0f, 6)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latencyReporter, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (latencyReporter, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 4); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + graph.flush(); + + audio.clear(); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + + audio.clear(); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[2]); +} + +TEST (AudioGraphProcessorTests, PdcMidiDelayLongerThanOneBlockAlignsWithDelayedProcessor) +{ + AudioGraphProcessor graph (midiLayout()); + const auto delayed = graph.addNode (std::make_unique (18)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (0, 8); + MidiBuffer midi; + const uint8 noteOn[] = { 0x90, 67, 100 }; + midi.addEvent (noteOn, 3, 6); + + graph.processBlock (audio, midi); + EXPECT_EQ (0, countMidiEvents (midi)); + + midi.clear(); + graph.processBlock (audio, midi); + EXPECT_EQ (0, countMidiEvents (midi)); + + midi.clear(); + graph.processBlock (audio, midi); + EXPECT_EQ (0, countMidiEvents (midi)); + + midi.clear(); + graph.processBlock (audio, midi); + EXPECT_EQ (2, countMidiEventsAt (midi, 0)); + EXPECT_EQ (2, countMidiEvents (midi)); +} + +TEST (AudioGraphProcessorTests, PdcFlushClearsPendingMidiCompensation) +{ + AudioGraphProcessor graph (midiLayout()); + const auto latencyReporter = graph.addNode (std::make_unique (10)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latencyReporter, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (latencyReporter, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (0, 8); + MidiBuffer midi; + const uint8 noteOn[] = { 0x90, 69, 100 }; + midi.addEvent (noteOn, 3, 7); + + graph.processBlock (audio, midi); + EXPECT_EQ (1, countMidiEventsAt (midi, 7)); + + graph.flush(); + + midi.clear(); + graph.processBlock (audio, midi); + EXPECT_EQ (0, countMidiEvents (midi)); + + midi.clear(); + graph.processBlock (audio, midi); + EXPECT_EQ (0, countMidiEvents (midi)); +} + +TEST (AudioGraphProcessorTests, PdcRecompileAfterRemovingLatencyPathClearsCompensation) +{ + AudioGraphProcessor graph; + const auto delayed = graph.addNode (std::make_unique (0.0f, 12)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_EQ (12, graph.getLatencySamples()); + EXPECT_EQ (1, graph.getAllocationStats().delayLines); + + EXPECT_TRUE (graph.removeNode (delayed)); + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_EQ (0, graph.getLatencySamples()); + EXPECT_EQ (0, graph.getAllocationStats().delayLines); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (1.0f, audio.getReadPointer (0)[0]); +} + +TEST (AudioGraphProcessorTests, WorkerThreadsProcessManyBlocksCorrectly) +{ + AudioGraphProcessor graph; + graph.setNumWorkerThreads (2); + + const auto left = graph.addNode (std::make_unique (0.5f)); + const auto right = graph.addNode (std::make_unique (0.5f)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (left, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (right, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (left, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (right, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + + for (int block = 0; block < 1000; ++block) + { + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (1.0f, audio.getReadPointer (0)[0]) << "block " << block; + } +} + +TEST (AudioGraphProcessorTests, WorkerThreadCountCanBeChangedAfterProcessing) +{ + AudioGraphProcessor graph; + + const auto node = graph.addNode (std::make_unique (0.5f)); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + + graph.setNumWorkerThreads (2); + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + + graph.setNumWorkerThreads (0); + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + + graph.setNumWorkerThreads (4); + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + + graph.setNumWorkerThreads (0); +} + +TEST (AudioGraphProcessorTests, WorkerThreadsContinueProcessingAfterIdleReset) +{ + AudioGraphProcessor graph; + graph.setNumWorkerThreads (2); + + const auto node = graph.addNode (std::make_unique (0.5f)); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + + for (int i = 0; i < 100; ++i) + std::this_thread::yield(); + + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + + graph.setNumWorkerThreads (0); +} + +TEST (AudioGraphProcessorTests, ZeroWorkerThreadsProcessesCorrectly) +{ + AudioGraphProcessor graph; + graph.setNumWorkerThreads (0); + + const auto node = graph.addNode (std::make_unique (0.5f)); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + + for (int block = 0; block < 10; ++block) + { + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]) << "block " << block; + } +} + +TEST (AudioGraphProcessorTests, WorkerThreadOutputMatchesSingleThreadOutputManyBlocks) +{ + auto runGraph = [] (int numWorkers) -> std::vector + { + AudioGraphProcessor graph; + graph.setNumWorkerThreads (numWorkers); + + const auto left = graph.addNode (std::make_unique (0.25f)); + const auto right = graph.addNode (std::make_unique (0.25f)); + + graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (left, 0) }); + graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (right, 0) }); + graph.addConnection ({ AudioGraphEndpoint::nodeOutput (left, 0), AudioGraphEndpoint::graphOutput (0) }); + graph.addConnection ({ AudioGraphEndpoint::nodeOutput (right, 0), AudioGraphEndpoint::graphOutput (0) }); + graph.commitChanges(); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + + std::vector results; + for (int block = 0; block < 100; ++block) + { + fillImpulse (audio); + graph.processBlock (audio, midi); + results.push_back (audio.getReadPointer (0)[0]); + } + + return results; + }; + + const auto singleResults = runGraph (0); + const auto multiResults = runGraph (2); + + ASSERT_EQ (singleResults.size(), multiResults.size()); + for (size_t i = 0; i < singleResults.size(); ++i) + EXPECT_FLOAT_EQ (singleResults[i], multiResults[i]) << "block " << i; +} + +TEST (AudioGraphProcessorTests, RapidWorkerThreadResizeDoesNotCrash) +{ + AudioGraphProcessor graph; + + const auto node = graph.addNode (std::make_unique (1.0f)); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + + const int threadCounts[] = { 0, 1, 2, 4, 2, 1, 0, 3, 0 }; + + for (int count : threadCounts) + { + graph.setNumWorkerThreads (count); + fillImpulse (audio); + graph.processBlock (audio, midi); + EXPECT_FLOAT_EQ (1.0f, audio.getReadPointer (0)[0]); + } +} + +#if ! defined(YUP_WASM) +TEST (AudioGraphProcessorTests, ProcessBlockDisablesDenormals) +{ + AudioGraphProcessor graph; + + auto proc = std::make_unique(); + auto* procPtr = proc.get(); + const auto node = graph.addNode (std::move (proc)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_TRUE (procPtr->denormalsWereDisabled); +} +#endif + +TEST (AudioGraphProcessorTests, ManyMidiEventsPassThroughGraphCorrectly) +{ + AudioBusLayout layout = midiLayout(); + AudioGraphProcessor graph (layout); + + const auto node = graph.addNode (std::make_unique()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (0, 128); + MidiBuffer midi; + + const int numEvents = 64; + for (int i = 0; i < numEvents; ++i) + { + const uint8 noteOn[] = { static_cast (0x90), static_cast (i % 128), 100 }; + midi.addEvent (noteOn, 3, i % 128); + } + + graph.processBlock (audio, midi); + + EXPECT_EQ (numEvents, countMidiEvents (midi)); +} + +TEST (AudioGraphProcessorTests, WorkerThreadsPdcCompensatesParallelPaths) +{ + // Mirror of CompensatesShorterParallelPaths with 2 worker threads. + // Verifies that PDC delay-line insertion still aligns a zero-latency branch + // with a 4-sample latent branch under parallel scheduling. + AudioGraphProcessor graph; + graph.setNumWorkerThreads (2); + + const auto dry = graph.addNode (std::make_unique (1.0f, 0)); + const auto latent = graph.addNode (std::make_unique (4)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latent, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (latent, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[4]); + EXPECT_EQ (4, graph.getLatencySamples()); + EXPECT_EQ (4, graph.getAllocationStats().totalCompensationSamples); +} + +TEST (AudioGraphProcessorTests, WorkerThreadsPdcMatchesSingleThreadOutput) +{ + // Build the same graph (dry path + serial 3+4 delay path) in both single-threaded + // and multi-threaded configurations, then verify sample-exact output over several + // blocks including the initial PDC fill period. + auto buildGraph = [] (int numWorkers) -> std::unique_ptr + { + auto g = std::make_unique(); + g->setNumWorkerThreads (numWorkers); + + const auto dry = g->addNode (std::make_unique (1.0f, 0)); + const auto firstDelay = g->addNode (std::make_unique (3)); + const auto secondDelay = g->addNode (std::make_unique (4)); + + g->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }); + g->addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }); + g->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (firstDelay, 0) }); + g->addConnection ({ AudioGraphEndpoint::nodeOutput (firstDelay, 0), AudioGraphEndpoint::nodeInput (secondDelay, 0) }); + g->addConnection ({ AudioGraphEndpoint::nodeOutput (secondDelay, 0), AudioGraphEndpoint::graphOutput (0) }); + g->commitChanges(); + + return g; + }; + + const auto single = buildGraph (0); + const auto multi = buildGraph (3); + + EXPECT_EQ (single->getLatencySamples(), multi->getLatencySamples()); + + AudioBuffer singleAudio (2, 16); + AudioBuffer multiAudio (2, 16); + MidiBuffer singleMidi; + MidiBuffer multiMidi; + + for (int block = 0; block < 8; ++block) + { + fillImpulse (singleAudio); + fillImpulse (multiAudio); + + single->processBlock (singleAudio, singleMidi); + multi->processBlock (multiAudio, multiMidi); + + for (int channel = 0; channel < 2; ++channel) + { + for (int sample = 0; sample < 16; ++sample) + { + EXPECT_FLOAT_EQ (singleAudio.getReadPointer (channel)[sample], + multiAudio.getReadPointer (channel)[sample]) + << "block=" << block << " ch=" << channel << " sample=" << sample; + } + } + } +} + +TEST (AudioGraphProcessorTests, MixedAudioMidiProcessorPassesThroughBothSignals) +{ + // A single mixed-bus node (audio bus 0, MIDI bus 1) applies gain to audio + // and passes MIDI through unchanged. Both signals must arrive at the graph + // output with the correct values and timestamps. + AudioGraphProcessor graph (mixedLayout()); + const auto node = graph.addNode (std::make_unique (0.5f)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::nodeInput (node, 1) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + const uint8 noteOn[] = { 0x90, 60, 100 }; + midi.addEvent (noteOn, 3, 5); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (1)[0]); + EXPECT_EQ (1, countMidiEventsAt (midi, 5)); + EXPECT_EQ (1, countMidiEvents (midi)); +} + +TEST (AudioGraphProcessorTests, MixedAudioMidiSerialChainProcessesBothSignals) +{ + // Two mixed-bus nodes in series: audio gain compounds (0.5 * 0.5 = 0.25), + // and MIDI events pass through both nodes unchanged. + AudioGraphProcessor graph (mixedLayout()); + const auto first = graph.addNode (std::make_unique (0.5f)); + const auto second = graph.addNode (std::make_unique (0.5f)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::nodeInput (first, 1) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 1), AudioGraphEndpoint::nodeInput (second, 1) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (second, 1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulse (audio); + const uint8 noteOn[] = { 0x90, 60, 100 }; + midi.addEvent (noteOn, 3, 3); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (0)[0]); + EXPECT_FLOAT_EQ (0.25f, audio.getReadPointer (1)[0]); + EXPECT_EQ (1, countMidiEventsAt (midi, 3)); + EXPECT_EQ (1, countMidiEvents (midi)); +} + +TEST (AudioGraphProcessorTests, MixedAudioMidiProcessorPdcCompensatesDirectPaths) +{ + // A mixed-bus node with a 5-sample ring-buffer audio delay sits in parallel + // with direct graph input → output connections on both buses. + // + // PDC must insert: + // - a 5-sample audio delay line on the direct audio path so both audio + // paths arrive at graphOutput(0) aligned (sum = 2.0f at sample 7) + // - a 5-sample MIDI delay line on the direct MIDI path so the direct event + // arrives at graphOutput(1) at position 7 + // + // The node passes MIDI through without physical delay, so its MIDI output + // arrives at graphOutput(1) at the original position (2), giving two total + // events at different timestamps. + AudioGraphProcessor graph (mixedLayout()); + const auto latentNode = graph.addNode (std::make_unique (5)); + + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latentNode, 0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (latentNode, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::nodeInput (latentNode, 1) }).wasOk()); + EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (latentNode, 1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + EXPECT_EQ (5, graph.getLatencySamples()); + EXPECT_EQ (2, graph.getAllocationStats().delayLines); + + AudioBuffer audio (2, 16); + MidiBuffer midi; + fillImpulseAt (audio, 2); + const uint8 noteOn[] = { 0x90, 60, 100 }; + midi.addEvent (noteOn, 3, 2); + + graph.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[2]); + EXPECT_FLOAT_EQ (2.0f, audio.getReadPointer (0)[7]); + EXPECT_EQ (1, countMidiEventsAt (midi, 2)); + EXPECT_EQ (1, countMidiEventsAt (midi, 7)); + EXPECT_EQ (2, countMidiEvents (midi)); +} + +TEST (AudioGraphProcessorTests, GetNodeIDsReturnsAllAddedNodes) +{ + AudioGraphProcessor graph; + + EXPECT_TRUE (graph.getNodeIDs().empty()); + + const auto idA = graph.addNode (std::make_unique()); + const auto idB = graph.addNode (std::make_unique()); + + const auto ids = graph.getNodeIDs(); + EXPECT_EQ (2u, ids.size()); + EXPECT_NE (ids.end(), std::find (ids.begin(), ids.end(), idA)); + EXPECT_NE (ids.end(), std::find (ids.begin(), ids.end(), idB)); + + EXPECT_TRUE (graph.removeNode (idA)); + const auto idsAfter = graph.getNodeIDs(); + EXPECT_EQ (1u, idsAfter.size()); + EXPECT_EQ (idsAfter.end(), std::find (idsAfter.begin(), idsAfter.end(), idA)); +} diff --git a/tests/yup_audio_gui.cpp b/tests/yup_audio_gui.cpp index 3b236b95b..0538b6de5 100644 --- a/tests/yup_audio_gui.cpp +++ b/tests/yup_audio_gui.cpp @@ -22,6 +22,7 @@ #include "yup_audio_gui/yup_AudioPeakProfile.cpp" #include "yup_audio_gui/yup_AudioPeakProfileCache.cpp" #include "yup_audio_gui/yup_AudioThumbnail.cpp" +#include "yup_audio_gui/yup_AudioGraphComponent.cpp" #include "yup_audio_gui/yup_AudioViewComponent.cpp" #include "yup_audio_gui/yup_CartesianPlane.cpp" #include "yup_audio_gui/yup_KMeterComponent.cpp" diff --git a/tests/yup_audio_gui/yup_AudioGraphComponent.cpp b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp new file mode 100644 index 000000000..ea9147c95 --- /dev/null +++ b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp @@ -0,0 +1,847 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include +#include +#include + +using namespace yup; + +namespace +{ +constexpr float pointTolerance = 0.001f; + +AudioBusLayout stereoLayout() +{ + return AudioBusLayout ({ AudioBus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2) }, + { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) }); +} + +void expectPointNear (Point actual, Point expected, float tolerance = pointTolerance) +{ + EXPECT_NEAR (expected.getX(), actual.getX(), tolerance); + EXPECT_NEAR (expected.getY(), actual.getY(), tolerance); +} + +void expectRectangleInside (const Rectangle& outer, const Rectangle& inner) +{ + EXPECT_LE (outer.getLeft(), inner.getLeft()); + EXPECT_LE (outer.getTop(), inner.getTop()); + EXPECT_GE (outer.getRight(), inner.getRight()); + EXPECT_GE (outer.getBottom(), inner.getBottom()); +} + +class TestProcessor : public AudioProcessor +{ +public: + TestProcessor() + : AudioProcessor ("Graph Component Test Processor", stereoLayout()) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer&, MidiBuffer&) override {} + + int getLatencySamples() override { return 0; } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } +}; + +class TestNodeView : public AudioGraphNodeView +{ +public: + explicit TestNodeView (AudioGraphNodeID nodeID, + String titleToUse = "Node", + int inputsToUse = 1, + int outputsToUse = 1, + int parametersToUse = 0, + int preferredWidthToUse = 140) + : AudioGraphNodeView (nodeID) + , title (std::move (titleToUse)) + , inputs (inputsToUse) + , outputs (outputsToUse) + , parameters (parametersToUse) + , preferredWidth (preferredWidthToUse) + { + } + + String getNodeTitle() const override { return title; } + + int getNumInputPorts() const override { return inputs; } + + int getNumOutputPorts() const override { return outputs; } + + int getNumParameterRows() const override { return parameters; } + + int getPreferredWidth() const override { return preferredWidth; } + + Color getNodeColor() const override { return nodeColor; } + + String getNodeSubtitle() const override { return subtitle; } + + PortInfo getInputPortInfo (int busIndex) const override + { + return { String ("input ") + String (busIndex), inputColor, PortKind::audio }; + } + + PortInfo getOutputPortInfo (int busIndex) const override + { + return { String ("output ") + String (busIndex), outputColor, PortKind::audio }; + } + + ParameterInfo getParameterInfo (int parameterIndex) const override + { + return { String ("parameter ") + String (parameterIndex), + String (parameterIndex), + parameterColor, + 0.25f * static_cast (parameterIndex + 1), + PortKind::parameter }; + } + + Color nodeColor = Color (0xff4466aa); + Color inputColor = Color (0xff112233); + Color outputColor = Color (0xff445566); + Color parameterColor = Color (0xff778899); + String subtitle = "Subtitle"; + +private: + String title; + int inputs = 1; + int outputs = 1; + int parameters = 0; + int preferredWidth = 140; +}; + +class RecordingGraphListener : public AudioGraphComponent::Listener +{ +public: + void connectionAdded (const AudioGraphConnection& connection) override + { + addedConnections.push_back (connection); + } + + void connectionRemoved (const AudioGraphConnection& connection) override + { + removedConnections.push_back (connection); + } + + void nodeViewMoved (AudioGraphNodeID nodeID, Point newCanvasPos) override + { + movedNodeID = nodeID; + movedCanvasPosition = newCanvasPos; + ++nodeMoveCount; + } + + std::vector addedConnections; + std::vector removedConnections; + AudioGraphNodeID movedNodeID; + Point movedCanvasPosition; + int nodeMoveCount = 0; +}; + +class AudioGraphComponentTests : public ::testing::Test +{ +protected: + void SetUp() override + { + graph = std::make_shared(); + component = std::make_unique (graph); + component->setBounds (0.0f, 0.0f, 800.0f, 600.0f); + } + + void TearDown() override + { + component.reset(); + graph.reset(); + } + + AudioGraphNodeID addProcessorNode() + { + return graph->addNode (std::make_unique()); + } + + TestNodeView* addNodeView (AudioGraphNodeID nodeID, + Point canvasPosition, + int inputs = 1, + int outputs = 1, + int parameters = 0, + int preferredWidth = 140) + { + auto view = std::make_unique (nodeID, "Node", inputs, outputs, parameters, preferredWidth); + auto* rawView = view.get(); + component->addNodeView (nodeID, std::move (view), canvasPosition); + return rawView; + } + + static MouseEvent mouseEventForComponent (MouseEvent::Buttons buttons, Point position) + { + return MouseEvent (buttons, KeyModifiers(), position); + } + + static MouseEvent mouseEventForSource (MouseEvent::Buttons buttons, Component* source, Point positionInSource) + { + return MouseEvent (buttons, KeyModifiers(), positionInSource, source); + } + + std::shared_ptr graph; + std::unique_ptr component; +}; + +} // namespace + +//============================================================================== +// AudioGraphNodeView Tests +//============================================================================== + +TEST (AudioGraphNodeViewTests, DefaultsDescribeBasicAudioNode) +{ + TestNodeView view (AudioGraphNodeID (1)); + + EXPECT_EQ (AudioGraphNodeID (1), view.getNodeID()); + EXPECT_EQ (String ("Node"), view.getNodeTitle()); + EXPECT_EQ (String ("Subtitle"), view.getNodeSubtitle()); + EXPECT_EQ (1, view.getNumInputPorts()); + EXPECT_EQ (1, view.getNumOutputPorts()); + EXPECT_EQ (0, view.getNumParameterRows()); + EXPECT_EQ (140, view.getPreferredWidth()); + EXPECT_EQ (82, view.getPreferredHeight()); + EXPECT_EQ (Color (0xff4466aa), view.getNodeColor()); +} + +TEST (AudioGraphNodeViewTests, PreferredHeightAccountsForPortsAndParameterRows) +{ + TestNodeView onePortNoParameters (AudioGraphNodeID (1), "One", 0, 0, 0); + TestNodeView threePortsTwoParameters (AudioGraphNodeID (2), "Three", 3, 2, 2); + TestNodeView twoOutputsFourParameters (AudioGraphNodeID (3), "Outputs", 1, 2, 4); + + EXPECT_EQ (82, onePortNoParameters.getPreferredHeight()); + EXPECT_EQ (180, threePortsTwoParameters.getPreferredHeight()); + EXPECT_EQ (206, twoOutputsFourParameters.getPreferredHeight()); +} + +TEST (AudioGraphNodeViewTests, PortCentersUseScaledLocalGeometry) +{ + TestNodeView view (AudioGraphNodeID (1), "Scaled", 2, 2, 0); + view.setBounds (0.0f, 0.0f, 140.0f, static_cast (view.getPreferredHeight())); + + expectPointNear (view.getInputPortCenter (0), { 5.0f, 66.0f }); + expectPointNear (view.getInputPortCenter (1), { 5.0f, 90.0f }); + expectPointNear (view.getOutputPortCenter (0), { 135.0f, 66.0f }); + expectPointNear (view.getOutputPortCenter (1), { 135.0f, 90.0f }); + + view.setViewScale (2.0f); + view.setBounds (0.0f, 0.0f, 280.0f, static_cast (view.getPreferredHeight()) * 2.0f); + + EXPECT_FLOAT_EQ (12.0f, view.getPortRadius()); + expectPointNear (view.getInputPortCenter (0), { 10.0f, 132.0f }); + expectPointNear (view.getOutputPortCenter (1), { 270.0f, 180.0f }); +} + +TEST (AudioGraphNodeViewTests, ViewScaleIsClamped) +{ + TestNodeView view (AudioGraphNodeID (1)); + + view.setViewScale (0.001f); + EXPECT_FLOAT_EQ (0.1f, view.getViewScale()); + EXPECT_FLOAT_EQ (0.6f, view.getPortRadius()); + + view.setViewScale (12.0f); + EXPECT_FLOAT_EQ (4.0f, view.getViewScale()); + EXPECT_FLOAT_EQ (24.0f, view.getPortRadius()); +} + +TEST (AudioGraphNodeViewTests, HitTestPortReturnsMatchingInputOrOutput) +{ + TestNodeView view (AudioGraphNodeID (1), "Ports", 2, 2, 0); + view.setBounds (0.0f, 0.0f, 140.0f, static_cast (view.getPreferredHeight())); + + auto inputHit = view.hitTestPort (view.getInputPortCenter (1)); + ASSERT_TRUE (inputHit.has_value()); + EXPECT_EQ (1, inputHit->busIndex); + EXPECT_TRUE (inputHit->isInput); + + auto outputHit = view.hitTestPort (view.getOutputPortCenter (0)); + ASSERT_TRUE (outputHit.has_value()); + EXPECT_EQ (0, outputHit->busIndex); + EXPECT_FALSE (outputHit->isInput); + + EXPECT_FALSE (view.hitTestPort ({ 70.0f, 12.0f }).has_value()); +} + +TEST (AudioGraphNodeViewTests, InvalidPortCentersReturnOrigin) +{ + TestNodeView view (AudioGraphNodeID (1), "Ports", 1, 1, 0); + view.setBounds (0.0f, 0.0f, 140.0f, static_cast (view.getPreferredHeight())); + + expectPointNear (view.getInputPortCenter (-1), Point()); + expectPointNear (view.getInputPortCenter (1), Point()); + expectPointNear (view.getOutputPortCenter (-1), Point()); + expectPointNear (view.getOutputPortCenter (1), Point()); +} + +TEST (AudioGraphNodeViewTests, PortKindColorsAreStable) +{ + EXPECT_EQ (Color (0xffffc43b), AudioGraphNodeView::getPortKindColor (AudioGraphNodeView::PortKind::audio)); + EXPECT_EQ (Color (0xffff4d67), AudioGraphNodeView::getPortKindColor (AudioGraphNodeView::PortKind::midi)); + EXPECT_EQ (Color (0xff2f8cff), AudioGraphNodeView::getPortKindColor (AudioGraphNodeView::PortKind::parameter)); +} + +//============================================================================== +// AudioGraphComponent View Management Tests +//============================================================================== + +TEST_F (AudioGraphComponentTests, ConstructorStoresGraphAndInitialSettings) +{ + EXPECT_EQ (graph.get(), component->getGraphProcessor()); + EXPECT_FLOAT_EQ (1.0f, component->getZoom()); + EXPECT_FLOAT_EQ (0.1f, component->getMinZoom()); + EXPECT_FLOAT_EQ (4.0f, component->getMaxZoom()); + EXPECT_FLOAT_EQ (8.0f, component->getWireHitDistance()); + EXPECT_FLOAT_EQ (4.0f, component->getDragWireThreshold()); +} + +TEST_F (AudioGraphComponentTests, AddNodeViewAddsChildAndAppliesCanvasTransform) +{ + const auto nodeID = addProcessorNode(); + auto* view = addNodeView (nodeID, { 100.0f, 50.0f }); + + ASSERT_EQ (view, component->getNodeView (nodeID)); + EXPECT_EQ (1, component->getNumChildComponents()); + EXPECT_EQ (component.get(), view->getParentComponent()); + expectPointNear (view->getBounds().getPosition(), component->canvasToScreen ({ 100.0f, 50.0f })); + EXPECT_FLOAT_EQ (static_cast (view->getPreferredWidth()), view->getWidth()); + EXPECT_FLOAT_EQ (static_cast (view->getPreferredHeight()), view->getHeight()); +} + +TEST_F (AudioGraphComponentTests, AddNodeViewReplacesExistingViewForNode) +{ + const auto nodeID = addProcessorNode(); + auto* firstView = addNodeView (nodeID, { 100.0f, 50.0f }); + ASSERT_EQ (firstView, component->getNodeView (nodeID)); + + auto replacement = std::make_unique (nodeID, "Replacement", 2, 2, 1, 180); + auto* replacementView = replacement.get(); + component->addNodeView (nodeID, std::move (replacement), { 20.0f, 30.0f }); + + EXPECT_EQ (replacementView, component->getNodeView (nodeID)); + EXPECT_EQ (1, component->getNumChildComponents()); + EXPECT_EQ (component.get(), replacementView->getParentComponent()); + expectPointNear (replacementView->getBounds().getPosition(), component->canvasToScreen ({ 20.0f, 30.0f })); +} + +TEST_F (AudioGraphComponentTests, RemoveNodeViewRemovesOnlyProcessorViews) +{ + const auto firstNodeID = addProcessorNode(); + const auto secondNodeID = addProcessorNode(); + + addNodeView (firstNodeID, { 0.0f, 0.0f }); + auto* secondView = addNodeView (secondNodeID, { 200.0f, 0.0f }); + ASSERT_EQ (2, component->getNumChildComponents()); + + component->removeNodeView (firstNodeID); + + EXPECT_EQ (nullptr, component->getNodeView (firstNodeID)); + EXPECT_EQ (secondView, component->getNodeView (secondNodeID)); + EXPECT_EQ (1, component->getNumChildComponents()); + + component->removeNodeView (AudioGraphNodeID (999)); + EXPECT_EQ (1, component->getNumChildComponents()); +} + +TEST_F (AudioGraphComponentTests, GraphInputAndOutputViewsAreReplaceable) +{ + auto input = std::make_unique (AudioGraphNodeID::invalid(), "Input", 0, 2); + auto* inputView = input.get(); + component->setGraphInputView (std::move (input), { -200.0f, 10.0f }); + + auto output = std::make_unique (AudioGraphNodeID::invalid(), "Output", 2, 0); + auto* outputView = output.get(); + component->setGraphOutputView (std::move (output), { 400.0f, 10.0f }); + + EXPECT_EQ (2, component->getNumChildComponents()); + EXPECT_EQ (component.get(), inputView->getParentComponent()); + EXPECT_EQ (component.get(), outputView->getParentComponent()); + + auto replacement = std::make_unique (AudioGraphNodeID::invalid(), "Input Replacement", 0, 1); + auto* replacementView = replacement.get(); + component->setGraphInputView (std::move (replacement), { -120.0f, 20.0f }); + + EXPECT_EQ (2, component->getNumChildComponents()); + EXPECT_EQ (component.get(), replacementView->getParentComponent()); + expectPointNear (replacementView->getBounds().getPosition(), component->canvasToScreen ({ -120.0f, 20.0f })); +} + +//============================================================================== +// AudioGraphComponent Viewport Tests +//============================================================================== + +TEST_F (AudioGraphComponentTests, ZoomAndOffsetRoundTripCanvasCoordinates) +{ + component->setCanvasOffset ({ 20.0f, -40.0f }); + component->setZoom (2.5f); + + const Point canvasPoint { 32.0f, 48.0f }; + const auto screenPoint = component->canvasToScreen (canvasPoint); + + expectPointNear (screenPoint, { 100.0f, 80.0f }); + expectPointNear (component->screenToCanvas (screenPoint), canvasPoint); +} + +TEST_F (AudioGraphComponentTests, ZoomAndThresholdSettersClampValues) +{ + component->setMinZoom (0.5f); + component->setMaxZoom (2.0f); + + component->setZoom (0.1f); + EXPECT_FLOAT_EQ (0.5f, component->getZoom()); + + component->setZoom (8.0f); + EXPECT_FLOAT_EQ (2.0f, component->getZoom()); + + component->setMinZoom (3.0f); + EXPECT_FLOAT_EQ (3.0f, component->getMinZoom()); + EXPECT_FLOAT_EQ (3.0f, component->getMaxZoom()); + EXPECT_FLOAT_EQ (3.0f, component->getZoom()); + + component->setWireHitDistance (-4.0f); + component->setDragWireThreshold (-9.0f); + + EXPECT_FLOAT_EQ (0.0f, component->getWireHitDistance()); + EXPECT_FLOAT_EQ (0.0f, component->getDragWireThreshold()); +} + +TEST_F (AudioGraphComponentTests, CenterViewOnNodesCentersSingleNode) +{ + const auto nodeID = addProcessorNode(); + auto* view = addNodeView (nodeID, { 100.0f, 50.0f }); + + component->centerViewOnNodes(); + + expectPointNear (view->getBounds().getCenter(), component->getLocalBounds().getCenter()); +} + +TEST_F (AudioGraphComponentTests, CenterViewOnEmptyGraphUsesComponentCenterAsOffset) +{ + component->setCanvasOffset ({ 10.0f, 20.0f }); + component->centerViewOnNodes(); + + expectPointNear (component->getCanvasOffset(), component->getLocalBounds().getCenter()); +} + +TEST_F (AudioGraphComponentTests, ResetViewRestoresUnitZoomAndCentersNodes) +{ + const auto nodeID = addProcessorNode(); + auto* view = addNodeView (nodeID, { 150.0f, 75.0f }); + + component->setZoom (2.0f); + component->setCanvasOffset ({ 0.0f, 0.0f }); + component->resetView(); + + EXPECT_FLOAT_EQ (1.0f, component->getZoom()); + expectPointNear (view->getBounds().getCenter(), component->getLocalBounds().getCenter()); +} + +TEST_F (AudioGraphComponentTests, ZoomToFitNodesFitsAllViewsInsidePadding) +{ + const auto firstNodeID = addProcessorNode(); + const auto secondNodeID = addProcessorNode(); + auto* firstView = addNodeView (firstNodeID, { 0.0f, 0.0f }); + auto* secondView = addNodeView (secondNodeID, { 600.0f, 420.0f }, 2, 2, 1, 180); + + component->zoomToFitNodes (50.0f, 1.0f); + + const Rectangle paddedViewport = component->getLocalBounds().reduced (50.0f); + expectRectangleInside (paddedViewport, firstView->getBounds()); + expectRectangleInside (paddedViewport, secondView->getBounds()); + EXPECT_LE (component->getZoom(), 1.0f); +} + +TEST_F (AudioGraphComponentTests, DeferredViewportOperationsRunOnResize) +{ + auto deferredComponent = std::make_unique (graph); + const auto nodeID = addProcessorNode(); + auto view = std::make_unique (nodeID); + auto* rawView = view.get(); + + deferredComponent->addNodeView (nodeID, std::move (view), { 100.0f, 100.0f }); + deferredComponent->zoomToFitNodes (24.0f, 0.75f); + deferredComponent->setBounds (0.0f, 0.0f, 320.0f, 240.0f); + + EXPECT_LE (deferredComponent->getZoom(), 0.75f); + EXPECT_EQ (deferredComponent.get(), rawView->getParentComponent()); +} + +TEST_F (AudioGraphComponentTests, MouseWheelZoomsAroundCursor) +{ + const auto cursor = Point { 280.0f, 220.0f }; + const auto canvasBefore = component->screenToCanvas (cursor); + + component->mouseWheel (mouseEventForComponent (MouseEvent::noButtons, cursor), MouseWheelData (0.0f, 2.0f)); + + EXPECT_GT (component->getZoom(), 1.0f); + expectPointNear (component->screenToCanvas (cursor), canvasBefore); +} + +TEST_F (AudioGraphComponentTests, SpacebarDragPansCanvas) +{ + const auto originalOffset = component->getCanvasOffset(); + + component->keyDown (KeyPress (KeyPress::spaceKey), Point()); + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, { 100.0f, 100.0f })); + component->mouseDrag (mouseEventForComponent (MouseEvent::leftButton, { 130.0f, 145.0f })); + component->mouseUp (mouseEventForComponent (MouseEvent::leftButton, { 130.0f, 145.0f })); + component->keyUp (KeyPress (KeyPress::spaceKey), Point()); + + expectPointNear (component->getCanvasOffset(), originalOffset + Point (30.0f, 45.0f)); +} + +//============================================================================== +// AudioGraphComponent Endpoint Tests +//============================================================================== + +TEST_F (AudioGraphComponentTests, EndpointScreenPositionUsesNodePortCenters) +{ + const auto nodeID = addProcessorNode(); + auto* view = addNodeView (nodeID, { 100.0f, 50.0f }, 2, 2); + + expectPointNear (component->getEndpointScreenPosition (AudioGraphEndpoint::nodeInput (nodeID, 1)), + component->getLocalPoint (view, view->getInputPortCenter (1))); + expectPointNear (component->getEndpointScreenPosition (AudioGraphEndpoint::nodeOutput (nodeID, 0)), + component->getLocalPoint (view, view->getOutputPortCenter (0))); +} + +TEST_F (AudioGraphComponentTests, EndpointScreenPositionUsesGraphBoundaryViewsWhenPresent) +{ + auto input = std::make_unique (AudioGraphNodeID::invalid(), "Input", 0, 2); + auto* inputView = input.get(); + component->setGraphInputView (std::move (input), { -200.0f, 10.0f }); + + auto output = std::make_unique (AudioGraphNodeID::invalid(), "Output", 2, 0); + auto* outputView = output.get(); + component->setGraphOutputView (std::move (output), { 400.0f, 10.0f }); + + expectPointNear (component->getEndpointScreenPosition (AudioGraphEndpoint::graphInput (1)), + component->getLocalPoint (inputView, inputView->getOutputPortCenter (1))); + expectPointNear (component->getEndpointScreenPosition (AudioGraphEndpoint::graphOutput (0)), + component->getLocalPoint (outputView, outputView->getInputPortCenter (0))); +} + +TEST_F (AudioGraphComponentTests, EndpointScreenPositionFallsBackForMissingGraphBoundaryViews) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + expectPointNear (component->getEndpointScreenPosition (AudioGraphEndpoint::graphInput (0)), + component->canvasToScreen ({ 4.0f, 110.0f })); + expectPointNear (component->getEndpointScreenPosition (AudioGraphEndpoint::graphOutput (1)), + component->canvasToScreen ({ 336.0f, 146.0f })); +} + +TEST_F (AudioGraphComponentTests, EndpointColorUsesPortMetadataAndFallback) +{ + const auto nodeID = addProcessorNode(); + auto* view = addNodeView (nodeID, { 100.0f, 50.0f }); + view->inputColor = Color (0xff010203); + view->outputColor = Color (0xff040506); + + EXPECT_EQ (Color (0xff010203), component->getEndpointColor (AudioGraphEndpoint::nodeInput (nodeID, 0))); + EXPECT_EQ (Color (0xff040506), component->getEndpointColor (AudioGraphEndpoint::nodeOutput (nodeID, 0))); + EXPECT_EQ (AudioGraphNodeView::getPortKindColor (AudioGraphNodeView::PortKind::audio), + component->getEndpointColor (AudioGraphEndpoint::nodeOutput (AudioGraphNodeID (999), 0))); +} + +TEST_F (AudioGraphComponentTests, GraphBoundaryEndpointColorUsesOppositeSidePortMetadata) +{ + auto input = std::make_unique (AudioGraphNodeID::invalid(), "Input", 0, 1); + auto* inputView = input.get(); + inputView->outputColor = Color (0xff101112); + component->setGraphInputView (std::move (input), { -200.0f, 10.0f }); + + auto output = std::make_unique (AudioGraphNodeID::invalid(), "Output", 1, 0); + auto* outputView = output.get(); + outputView->inputColor = Color (0xff131415); + component->setGraphOutputView (std::move (output), { 400.0f, 10.0f }); + + EXPECT_EQ (Color (0xff101112), component->getEndpointColor (AudioGraphEndpoint::graphInput (0))); + EXPECT_EQ (Color (0xff131415), component->getEndpointColor (AudioGraphEndpoint::graphOutput (0))); +} + +TEST_F (AudioGraphComponentTests, MouseDownOnEndpointArmsPendingWire) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + const auto endpoint = AudioGraphEndpoint::nodeOutput (nodeID, 0); + const auto screenPosition = component->getEndpointScreenPosition (endpoint); + + EXPECT_FALSE (component->isPendingWireVisible()); + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, screenPosition)); + + ASSERT_TRUE (component->isPendingWireVisible()); + ASSERT_TRUE (component->getPendingWireEndpoint().has_value()); + EXPECT_EQ (endpoint, *component->getPendingWireEndpoint()); + expectPointNear (component->getPendingWireEndPosition(), screenPosition); +} + +TEST_F (AudioGraphComponentTests, DragPastThresholdTurnsPendingWireIntoDraggedWire) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + const auto start = component->getEndpointScreenPosition (AudioGraphEndpoint::nodeOutput (nodeID, 0)); + const auto end = start + Point (30.0f, 10.0f); + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, start)); + component->mouseDrag (mouseEventForComponent (MouseEvent::leftButton, end)); + + EXPECT_TRUE (component->isPendingWireVisible()); + expectPointNear (component->getPendingWireEndPosition(), end); +} + +TEST_F (AudioGraphComponentTests, ClickingAwayClearsArmedPendingWire) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + const auto endpointPosition = component->getEndpointScreenPosition (AudioGraphEndpoint::nodeOutput (nodeID, 0)); + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, endpointPosition)); + ASSERT_TRUE (component->isPendingWireVisible()); + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, { 20.0f, 20.0f })); + + EXPECT_FALSE (component->isPendingWireVisible()); + EXPECT_FALSE (component->getPendingWireEndpoint().has_value()); +} + +//============================================================================== +// AudioGraphComponent Interaction Tests +//============================================================================== + +TEST_F (AudioGraphComponentTests, ClickingCompatibleEndpointsAddsConnectionAndNotifiesListener) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + auto inputView = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); + component->setGraphInputView (std::move (inputView), { -120.0f, 50.0f }); + + RecordingGraphListener listener; + component->addListener (&listener); + + const auto source = AudioGraphEndpoint::graphInput (0); + const auto destination = AudioGraphEndpoint::nodeInput (nodeID, 0); + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (source))); + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (destination))); + + const auto connections = graph->getConnections(); + ASSERT_EQ (1u, connections.size()); + EXPECT_EQ (AudioGraphConnection (source, destination), connections.front()); + + ASSERT_EQ (1u, listener.addedConnections.size()); + EXPECT_EQ (AudioGraphConnection (source, destination), listener.addedConnections.front()); + + component->removeListener (&listener); +} + +TEST_F (AudioGraphComponentTests, DraggingCompatibleEndpointsAddsConnectionAndClearsPendingWire) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + auto inputView = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); + component->setGraphInputView (std::move (inputView), { -120.0f, 50.0f }); + + const auto source = AudioGraphEndpoint::graphInput (0); + const auto destination = AudioGraphEndpoint::nodeInput (nodeID, 0); + const auto sourcePosition = component->getEndpointScreenPosition (source); + const auto destinationPosition = component->getEndpointScreenPosition (destination); + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, sourcePosition)); + component->mouseDrag (mouseEventForComponent (MouseEvent::leftButton, sourcePosition + Point (20.0f, 0.0f))); + component->mouseUp (mouseEventForComponent (MouseEvent::leftButton, destinationPosition)); + + const auto connections = graph->getConnections(); + ASSERT_EQ (1u, connections.size()); + EXPECT_EQ (AudioGraphConnection (source, destination), connections.front()); + EXPECT_FALSE (component->isPendingWireVisible()); + EXPECT_FALSE (component->getPendingWireEndpoint().has_value()); +} + +TEST_F (AudioGraphComponentTests, IncompatibleEndpointPairKeepsNewEndpointArmedAndDoesNotConnect) +{ + const auto firstNodeID = addProcessorNode(); + const auto secondNodeID = addProcessorNode(); + addNodeView (firstNodeID, { 100.0f, 50.0f }); + addNodeView (secondNodeID, { 320.0f, 50.0f }); + + const auto firstInput = AudioGraphEndpoint::nodeInput (firstNodeID, 0); + const auto secondInput = AudioGraphEndpoint::nodeInput (secondNodeID, 0); + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (firstInput))); + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (secondInput))); + + EXPECT_TRUE (graph->getConnections().empty()); + ASSERT_TRUE (component->getPendingWireEndpoint().has_value()); + EXPECT_EQ (secondInput, *component->getPendingWireEndpoint()); +} + +TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesAllConnectionsForEndpoint) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + auto inputView = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); + component->setGraphInputView (std::move (inputView), { -120.0f, 50.0f }); + + const AudioGraphConnection connection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (nodeID, 0) }; + ASSERT_TRUE (graph->addConnection (connection).wasOk()); + ASSERT_TRUE (graph->commitChanges().wasOk()); + + RecordingGraphListener listener; + component->addListener (&listener); + + component->mouseDown (mouseEventForComponent (MouseEvent::rightButton, component->getEndpointScreenPosition (connection.source))); + + EXPECT_TRUE (graph->getConnections().empty()); + ASSERT_EQ (1u, listener.removedConnections.size()); + EXPECT_EQ (connection, listener.removedConnections.front()); + + component->removeListener (&listener); +} + +TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesMultipleConnectionsForEndpoint) +{ + const auto firstNodeID = addProcessorNode(); + const auto secondNodeID = addProcessorNode(); + addNodeView (firstNodeID, { 100.0f, 50.0f }); + addNodeView (secondNodeID, { 100.0f, 190.0f }); + + auto inputView = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); + component->setGraphInputView (std::move (inputView), { -120.0f, 50.0f }); + + const AudioGraphConnection firstConnection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (firstNodeID, 0) }; + const AudioGraphConnection secondConnection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (secondNodeID, 0) }; + + ASSERT_TRUE (graph->addConnection (firstConnection).wasOk()); + ASSERT_TRUE (graph->addConnection (secondConnection).wasOk()); + ASSERT_TRUE (graph->commitChanges().wasOk()); + + RecordingGraphListener listener; + component->addListener (&listener); + + component->mouseDown (mouseEventForComponent (MouseEvent::rightButton, component->getEndpointScreenPosition (firstConnection.source))); + + EXPECT_TRUE (graph->getConnections().empty()); + ASSERT_EQ (2u, listener.removedConnections.size()); + EXPECT_EQ (firstConnection, listener.removedConnections[0]); + EXPECT_EQ (secondConnection, listener.removedConnections[1]); + + component->removeListener (&listener); +} + +TEST_F (AudioGraphComponentTests, RightClickConnectionRemovesSingleConnection) +{ + const auto nodeID = addProcessorNode(); + addNodeView (nodeID, { 100.0f, 50.0f }); + + auto inputView = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); + component->setGraphInputView (std::move (inputView), { -120.0f, 50.0f }); + + const AudioGraphConnection connection { AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (nodeID, 0) }; + ASSERT_TRUE (graph->addConnection (connection).wasOk()); + ASSERT_TRUE (graph->commitChanges().wasOk()); + + const auto midpoint = (component->getEndpointScreenPosition (connection.source) + + component->getEndpointScreenPosition (connection.destination)) + * 0.5f; + component->setWireHitDistance (64.0f); + + component->mouseDown (mouseEventForComponent (MouseEvent::rightButton, midpoint)); + + EXPECT_TRUE (graph->getConnections().empty()); +} + +TEST_F (AudioGraphComponentTests, DraggingNodeUpdatesCanvasPositionAndNotifiesListener) +{ + const auto nodeID = addProcessorNode(); + auto* view = addNodeView (nodeID, { 100.0f, 50.0f }); + RecordingGraphListener listener; + component->addListener (&listener); + + const auto localStart = view->getLocalBounds().getCenter(); + component->mouseDown (mouseEventForSource (MouseEvent::leftButton, view, localStart)); + component->mouseDrag (mouseEventForSource (MouseEvent::leftButton, view, localStart + Point (25.0f, 35.0f))); + component->mouseUp (mouseEventForComponent (MouseEvent::leftButton, component->getLocalBounds().getCenter())); + + EXPECT_EQ (1, listener.nodeMoveCount); + EXPECT_EQ (nodeID, listener.movedNodeID); + expectPointNear (listener.movedCanvasPosition, { 125.0f, 85.0f }); + expectPointNear (view->getBounds().getPosition(), component->canvasToScreen ({ 125.0f, 85.0f })); + + component->removeListener (&listener); +} + +TEST_F (AudioGraphComponentTests, DraggingGraphBoundaryViewDoesNotNotifyNodeMoveListener) +{ + auto graphInput = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); + auto* graphInputView = graphInput.get(); + component->setGraphInputView (std::move (graphInput), { -120.0f, 50.0f }); + + RecordingGraphListener listener; + component->addListener (&listener); + + const auto localStart = graphInputView->getLocalBounds().getCenter(); + component->mouseDown (mouseEventForSource (MouseEvent::leftButton, graphInputView, localStart)); + component->mouseDrag (mouseEventForSource (MouseEvent::leftButton, graphInputView, localStart + Point (25.0f, 35.0f))); + component->mouseUp (mouseEventForComponent (MouseEvent::leftButton, component->getLocalBounds().getCenter())); + + EXPECT_EQ (0, listener.nodeMoveCount); + expectPointNear (graphInputView->getBounds().getPosition(), component->canvasToScreen ({ -95.0f, 85.0f })); + + component->removeListener (&listener); +} diff --git a/tests/yup_audio_plugin_host.cpp b/tests/yup_audio_plugin_host.cpp new file mode 100644 index 000000000..9f3cd1d60 --- /dev/null +++ b/tests/yup_audio_plugin_host.cpp @@ -0,0 +1,25 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_audio_plugin_host/yup_AudioPluginDescription.cpp" +#include "yup_audio_plugin_host/yup_AudioPluginScanner.cpp" +#include "yup_audio_plugin_host/yup_AudioPluginInstance.cpp" +#include "yup_audio_plugin_host/yup_AudioPluginState.cpp" diff --git a/tests/yup_audio_plugin_host/yup_AudioPluginDescription.cpp b/tests/yup_audio_plugin_host/yup_AudioPluginDescription.cpp new file mode 100644 index 000000000..1203af3ec --- /dev/null +++ b/tests/yup_audio_plugin_host/yup_AudioPluginDescription.cpp @@ -0,0 +1,56 @@ +#include + +#include + +using namespace yup; + +TEST (AudioPluginDescriptionTests, DefaultFormatTypeIsUnknown) +{ + AudioPluginDescription desc; + EXPECT_EQ (AudioPluginFormatType::unknown, desc.formatType); +} + +TEST (AudioPluginDescriptionTests, IsInstrumentDefaultsFalse) +{ + AudioPluginDescription desc; + EXPECT_FALSE (desc.isInstrument); +} + +TEST (AudioPluginDescriptionTests, MidiPortCountsDefaultToZero) +{ + AudioPluginDescription desc; + EXPECT_EQ (0, desc.numMidiInputPorts); + EXPECT_EQ (0, desc.numMidiOutputPorts); +} + +TEST (AudioPluginDescriptionTests, EqualityMatchesAllFields) +{ + AudioPluginDescription a; + a.name = "Synth"; + a.identifier = "com.vendor.synth"; + a.formatType = AudioPluginFormatType::vst3; + + AudioPluginDescription b = a; + EXPECT_EQ (a, b); + + b.version = "2.0"; + EXPECT_NE (a, b); +} + +TEST (AudioPluginHostContextTests, DefaultSampleRate) +{ + AudioPluginHostContext ctx; + EXPECT_FLOAT_EQ (44100.0f, ctx.sampleRate); +} + +TEST (AudioPluginHostContextTests, DefaultMaxBlockSize) +{ + AudioPluginHostContext ctx; + EXPECT_EQ (512, ctx.maxBlockSize); +} + +TEST (AudioPluginHostContextTests, DefaultsToRealtimeRendering) +{ + AudioPluginHostContext ctx; + EXPECT_FALSE (ctx.isNonRealtime); +} diff --git a/tests/yup_audio_plugin_host/yup_AudioPluginInstance.cpp b/tests/yup_audio_plugin_host/yup_AudioPluginInstance.cpp new file mode 100644 index 000000000..07799367d --- /dev/null +++ b/tests/yup_audio_plugin_host/yup_AudioPluginInstance.cpp @@ -0,0 +1,351 @@ +#include + +#include + +using namespace yup; + +namespace +{ + +class FakePluginInstance : public AudioPluginInstance +{ +public: + explicit FakePluginInstance (bool supportsDoublePrecision = false) + : AudioPluginInstance (makeDescription(), + AudioBusLayout ({ AudioBus ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, 2) }, + { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) })) + , supportsDoublePrecision (supportsDoublePrecision) + { + } + + void prepareToPlay (float, int) override { prepared = true; } + + void releaseResources() override { prepared = false; } + + void processBlock (AudioBuffer& audio, MidiBuffer&) override + { + audio.clear(); + processCallCount++; + } + + void processBlock (AudioBuffer& audio, MidiBuffer&) override + { + audio.clear(); + doubleProcessCallCount++; + } + + bool supportsDoublePrecisionProcessing() const override { return supportsDoublePrecision; } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock& block) override + { + lastLoadedState = block; + return Result::ok(); + } + + Result saveStateIntoMemory (MemoryBlock& block) override + { + block = lastLoadedState; + return Result::ok(); + } + + bool hasEditor() const override { return false; } + + bool prepared = false; + int processCallCount = 0; + int doubleProcessCallCount = 0; + MemoryBlock lastLoadedState; + +private: + bool supportsDoublePrecision = false; + + static AudioPluginDescription makeDescription() + { + AudioPluginDescription d; + d.name = "FakePlugin"; + d.identifier = "fake.plugin"; + d.formatType = AudioPluginFormatType::vst3; + return d; + } +}; + +class CountOnlyPluginInstance : public AudioPluginInstance +{ +public: + CountOnlyPluginInstance() + : AudioPluginInstance (makeDescription(), + AudioBusLayout ({ AudioBus ("MIDI Input", AudioBus::Type::MIDI, AudioBus::Direction::Input, 1) }, + { AudioBus ("MIDI Output", AudioBus::Type::MIDI, AudioBus::Direction::Output, 1) })) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer&, MidiBuffer&) override {} + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + +private: + static AudioPluginDescription makeDescription() + { + AudioPluginDescription d; + d.name = "CountOnlyPlugin"; + d.identifier = "count.only"; + d.formatType = AudioPluginFormatType::clap; + return d; + } +}; + +class BypassPluginInstance : public AudioPluginInstance +{ +public: + BypassPluginInstance (int numInputChannels, int numOutputChannels) + : AudioPluginInstance (makeDescription (numInputChannels, numOutputChannels), + makeLayout (numInputChannels, numOutputChannels)) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer& audio, MidiBuffer& midi) override + { + if (isBypassed()) + { + processBlockBypassed (audio, midi); + return; + } + + audio.clear(); + } + + int getCurrentPreset() const noexcept override { return 0; } + + void setCurrentPreset (int) noexcept override {} + + int getNumPresets() const override { return 0; } + + String getPresetName (int) const override { return {}; } + + void setPresetName (int, StringRef) override {} + + Result loadStateFromMemory (const MemoryBlock&) override { return Result::ok(); } + + Result saveStateIntoMemory (MemoryBlock&) override { return Result::ok(); } + + bool hasEditor() const override { return false; } + +private: + static AudioPluginDescription makeDescription (int numInputChannels, int numOutputChannels) + { + AudioPluginDescription d; + d.name = "BypassPlugin"; + d.identifier = "bypass.plugin"; + d.formatType = AudioPluginFormatType::vst3; + d.numInputChannels = numInputChannels; + d.numOutputChannels = numOutputChannels; + return d; + } + + static AudioBusLayout makeLayout (int numInputChannels, int numOutputChannels) + { + std::vector inputs; + std::vector outputs; + + if (numInputChannels > 0) + inputs.emplace_back ("Input", AudioBus::Type::Audio, AudioBus::Direction::Input, numInputChannels); + + if (numOutputChannels > 0) + outputs.emplace_back ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, numOutputChannels); + + return AudioBusLayout (std::move (inputs), std::move (outputs)); + } +}; + +} // namespace + +class AudioPluginInstanceTests : public ::testing::Test +{ +protected: + FakePluginInstance instance; +}; + +TEST_F (AudioPluginInstanceTests, DescriptionIsStoredCorrectly) +{ + EXPECT_EQ ("FakePlugin", instance.getDescription().name); + EXPECT_EQ (AudioPluginFormatType::vst3, instance.getDescription().formatType); +} + +TEST_F (AudioPluginInstanceTests, PrepareToPlaySetsPreparedFlag) +{ + instance.setPlaybackConfiguration (44100.0f, 512); + EXPECT_TRUE (instance.prepared); + EXPECT_FLOAT_EQ (44100.0f, instance.getSampleRate()); + EXPECT_EQ (512, instance.getSamplesPerBlock()); +} + +TEST_F (AudioPluginInstanceTests, ReleaseResourcesClearsPreparedFlag) +{ + instance.prepareToPlay (44100.0f, 512); + instance.releaseResources(); + EXPECT_FALSE (instance.prepared); +} + +TEST_F (AudioPluginInstanceTests, ProcessBlockIncrementsCounter) +{ + AudioBuffer audio (2, 512); + MidiBuffer midi; + + instance.prepareToPlay (44100.0f, 512); + instance.processBlock (audio, midi); + + EXPECT_EQ (1, instance.processCallCount); +} + +TEST_F (AudioPluginInstanceTests, DefaultsToSinglePrecision) +{ + EXPECT_FALSE (instance.supportsDoublePrecisionProcessing()); + EXPECT_EQ (AudioProcessor::ProcessingPrecision::singlePrecision, instance.getProcessingPrecision()); + EXPECT_FALSE (instance.isUsingDoublePrecision()); +} + +TEST_F (AudioPluginInstanceTests, UsesDoublePrecisionWhenSupported) +{ + FakePluginInstance doublePrecisionInstance (true); + + doublePrecisionInstance.setProcessingPrecision (AudioProcessor::ProcessingPrecision::doublePrecision); + + EXPECT_TRUE (doublePrecisionInstance.supportsDoublePrecisionProcessing()); + EXPECT_EQ (AudioProcessor::ProcessingPrecision::doublePrecision, doublePrecisionInstance.getProcessingPrecision()); + EXPECT_TRUE (doublePrecisionInstance.isUsingDoublePrecision()); +} + +TEST_F (AudioPluginInstanceTests, CanReturnToSinglePrecisionAfterUsingDoublePrecision) +{ + FakePluginInstance doublePrecisionInstance (true); + + doublePrecisionInstance.setProcessingPrecision (AudioProcessor::ProcessingPrecision::doublePrecision); + doublePrecisionInstance.setProcessingPrecision (AudioProcessor::ProcessingPrecision::singlePrecision); + + EXPECT_EQ (AudioProcessor::ProcessingPrecision::singlePrecision, doublePrecisionInstance.getProcessingPrecision()); + EXPECT_FALSE (doublePrecisionInstance.isUsingDoublePrecision()); +} + +TEST_F (AudioPluginInstanceTests, DoublePrecisionProcessBlockUsesDoublePath) +{ + FakePluginInstance doublePrecisionInstance (true); + AudioBuffer audio (2, 512); + MidiBuffer midi; + + audio.setSample (0, 0, 1.0); + + doublePrecisionInstance.setProcessingPrecision (AudioProcessor::ProcessingPrecision::doublePrecision); + doublePrecisionInstance.prepareToPlay (44100.0f, 512); + doublePrecisionInstance.processBlock (audio, midi); + + EXPECT_EQ (0, doublePrecisionInstance.processCallCount); + EXPECT_EQ (1, doublePrecisionInstance.doubleProcessCallCount); + EXPECT_DOUBLE_EQ (0.0, audio.getSample (0, 0)); +} + +TEST_F (AudioPluginInstanceTests, BusLayoutMatchesConstructorArg) +{ + EXPECT_EQ (1, instance.getNumAudioInputs()); + EXPECT_EQ (1, instance.getNumAudioOutputs()); +} + +TEST_F (AudioPluginInstanceTests, DefaultsToRealtimeAndNotBypassed) +{ + EXPECT_FALSE (instance.isNonRealtime()); + EXPECT_FALSE (instance.isBypassed()); +} + +TEST_F (AudioPluginInstanceTests, RenderModeAndBypassCanBeSetByHost) +{ + instance.setNonRealtime (true); + instance.setBypassed (true); + + EXPECT_TRUE (instance.isNonRealtime()); + EXPECT_TRUE (instance.isBypassed()); +} + +TEST (AudioPluginInstanceBusLayoutTests, AudioInputAndOutputCountsIgnoreMidiBuses) +{ + CountOnlyPluginInstance instanceWithMidiBuses; + + EXPECT_EQ (0, instanceWithMidiBuses.getNumAudioInputs()); + EXPECT_EQ (0, instanceWithMidiBuses.getNumAudioOutputs()); +} + +TEST (AudioPluginInstanceBypassTests, CopiesMatchingInputChannelsAndClearsExtraOutputs) +{ + BypassPluginInstance bypassInstance (1, 2); + AudioBuffer audio (2, 8); + MidiBuffer midi; + + audio.setSample (0, 0, 0.75f); + audio.setSample (1, 0, 0.5f); + + bypassInstance.setBypassed (true); + bypassInstance.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.75f, audio.getSample (0, 0)); + EXPECT_FLOAT_EQ (0.0f, audio.getSample (1, 0)); +} + +TEST (AudioPluginInstanceBypassTests, ClearsInstrumentOutputsWithoutInputs) +{ + BypassPluginInstance bypassInstance (0, 2); + AudioBuffer audio (2, 8); + MidiBuffer midi; + + audio.setSample (0, 0, 0.75f); + audio.setSample (1, 0, 0.5f); + + bypassInstance.setBypassed (true); + bypassInstance.processBlock (audio, midi); + + EXPECT_FLOAT_EQ (0.0f, audio.getSample (0, 0)); + EXPECT_FLOAT_EQ (0.0f, audio.getSample (1, 0)); +} + +TEST (AudioPluginInstanceBypassTests, SupportsDoublePrecisionBypass) +{ + BypassPluginInstance bypassInstance (1, 2); + AudioBuffer audio (2, 8); + MidiBuffer midi; + + audio.setSample (0, 0, 0.75); + audio.setSample (1, 0, 0.5); + + bypassInstance.processBlockBypassed (audio, midi); + + EXPECT_DOUBLE_EQ (0.75, audio.getSample (0, 0)); + EXPECT_DOUBLE_EQ (0.0, audio.getSample (1, 0)); +} diff --git a/tests/yup_audio_plugin_host/yup_AudioPluginScanner.cpp b/tests/yup_audio_plugin_host/yup_AudioPluginScanner.cpp new file mode 100644 index 000000000..9966c8f74 --- /dev/null +++ b/tests/yup_audio_plugin_host/yup_AudioPluginScanner.cpp @@ -0,0 +1,105 @@ +#include + +#include + +using namespace yup; + +namespace +{ + +class FakeFormat : public AudioPluginFormat +{ +public: + explicit FakeFormat (AudioPluginFormatType type) + : fakeType (type) + { + } + + AudioPluginFormatType getFormatType() const override { return fakeType; } + + String getFormatName() const override { return "Fake"; } + + StringArray getFileExtensions() const override { return { ".vst3" }; } + + FileSearchPath getDefaultSearchPaths() const override { return {}; } + + ResultValue> scanFile (const File& file) override + { + if (file.getFileName() == "bad.vst3") + return makeResultValueFail ("unsupported"); + + AudioPluginDescription desc; + desc.formatType = fakeType; + desc.name = file.getFileNameWithoutExtension(); + desc.identifier = "fake." + file.getFileNameWithoutExtension(); + return makeResultValueOk (std::vector { desc }); + } + + ResultValue> loadPlugin ( + const AudioPluginDescription&, + const AudioPluginHostContext&) override + { + return makeResultValueFail ("not implemented"); + } + +private: + AudioPluginFormatType fakeType; +}; + +} // namespace + +class AudioPluginScannerTests : public ::testing::Test +{ +protected: + void SetUp() override + { + scanner.addFormat (std::make_unique (AudioPluginFormatType::vst3)); + } + + AudioPluginScanner scanner; +}; + +TEST_F (AudioPluginScannerTests, EmptyPathReturnsNoResults) +{ + auto result = scanner.scan (FileSearchPath {}); + EXPECT_TRUE (result.discovered.empty()); + EXPECT_TRUE (result.failedPaths.empty()); +} + +TEST_F (AudioPluginScannerTests, BadPluginAppearsInFailedPaths) +{ + File tmpDir = File::getSpecialLocation (File::tempDirectory).getChildFile ("yup_scanner_test"); + tmpDir.createDirectory(); + tmpDir.getChildFile ("bad.vst3").create(); + + FileSearchPath path; + path.add (tmpDir); + + auto result = scanner.scan (path); + EXPECT_TRUE (result.discovered.empty()); + EXPECT_EQ (1, (int) result.failedPaths.size()); + + tmpDir.deleteRecursively(); +} + +TEST_F (AudioPluginScannerTests, GoodPluginAppearsInDiscovered) +{ + File tmpDir = File::getSpecialLocation (File::tempDirectory).getChildFile ("yup_scanner_test2"); + tmpDir.createDirectory(); + tmpDir.getChildFile ("MySynth.vst3").create(); + + FileSearchPath path; + path.add (tmpDir); + + auto result = scanner.scan (path); + ASSERT_EQ (1, (int) result.discovered.size()); + EXPECT_EQ ("MySynth", result.discovered[0].name); + + tmpDir.deleteRecursively(); +} + +TEST_F (AudioPluginScannerTests, AddingDuplicateFormatTypeReplacesExisting) +{ + scanner.addFormat (std::make_unique (AudioPluginFormatType::vst3)); + EXPECT_EQ (1, scanner.getNumFormats()); +} diff --git a/tests/yup_audio_plugin_host/yup_AudioPluginState.cpp b/tests/yup_audio_plugin_host/yup_AudioPluginState.cpp new file mode 100644 index 000000000..551f5a3bf --- /dev/null +++ b/tests/yup_audio_plugin_host/yup_AudioPluginState.cpp @@ -0,0 +1,82 @@ +#include + +#include + +using namespace yup; + +namespace +{ + +class StatefulFakeInstance : public AudioPluginInstance +{ +public: + StatefulFakeInstance() + : AudioPluginInstance (AudioPluginDescription {}, + AudioBusLayout ({}, {})) + { + } + + void prepareToPlay (float, int) override {} + + void releaseResources() override {} + + void processBlock (AudioBuffer&, MidiBuffer&) override {} + + int getCurrentPreset() const noexcept override { return currentPreset; } + + void setCurrentPreset (int i) noexcept override { currentPreset = i; } + + int getNumPresets() const override { return 3; } + + String getPresetName (int i) const override { return "Preset " + String (i); } + + void setPresetName (int, StringRef) override {} + + bool hasEditor() const override { return false; } + + Result loadStateFromMemory (const MemoryBlock& block) override + { + storedState = block; + return Result::ok(); + } + + Result saveStateIntoMemory (MemoryBlock& block) override + { + block = storedState; + return Result::ok(); + } + + int currentPreset = 0; + MemoryBlock storedState; +}; + +} // namespace + +TEST (AudioPluginStateTests, RoundTripPreservesData) +{ + StatefulFakeInstance instance; + + const char rawData[] = { 0x01, 0x02, 0x03, 0x04 }; + MemoryBlock written (rawData, sizeof (rawData)); + + EXPECT_TRUE (instance.loadStateFromMemory (written).wasOk()); + + MemoryBlock readBack; + EXPECT_TRUE (instance.saveStateIntoMemory (readBack).wasOk()); + + EXPECT_EQ (written, readBack); +} + +TEST (AudioPluginStateTests, PresetNameByIndex) +{ + StatefulFakeInstance instance; + EXPECT_EQ ("Preset 0", instance.getPresetName (0)); + EXPECT_EQ ("Preset 2", instance.getPresetName (2)); +} + +TEST (AudioPluginStateTests, SetAndGetCurrentPreset) +{ + StatefulFakeInstance instance; + instance.setCurrentPreset (2); + EXPECT_EQ (2, instance.getCurrentPreset()); +} diff --git a/tests/yup_core/yup_ResultValue.cpp b/tests/yup_core/yup_ResultValue.cpp index 6363f22f9..a75dd3611 100644 --- a/tests/yup_core/yup_ResultValue.cpp +++ b/tests/yup_core/yup_ResultValue.cpp @@ -137,3 +137,80 @@ TEST (ResultValueTests, InequalityOperator) EXPECT_TRUE (success1 != failure1); EXPECT_TRUE (failure1 != failure3); } + +TEST (ResultValueTests, MakeResultValueOkDeducesTypeFromArgument) +{ + ResultValue result = makeResultValueOk (42); + EXPECT_TRUE (result.wasOk()); + EXPECT_FALSE (result.failed()); + EXPECT_EQ (result.getValue(), 42); +} + +TEST (ResultValueTests, MakeResultValueOkWorksWithReturnDeduction) +{ + auto makeInt = []() -> ResultValue + { + return makeResultValueOk (1337); + }; + auto makeStr = []() -> ResultValue + { + return makeResultValueOk (String ("hello")); + }; + + auto intResult = makeInt(); + EXPECT_TRUE (intResult.wasOk()); + EXPECT_EQ (intResult.getValue(), 1337); + + auto strResult = makeStr(); + EXPECT_TRUE (strResult.wasOk()); + EXPECT_EQ (strResult.getValue(), String ("hello")); +} + +TEST (ResultValueTests, MakeResultValueOkWithImplicitConversion) +{ + ResultValue result = makeResultValueOk (42); + EXPECT_TRUE (result.wasOk()); + EXPECT_DOUBLE_EQ (result.getValue(), 42.0); +} + +TEST (ResultValueTests, MakeResultValueFailDeducesForAnyType) +{ + ResultValue intResult = makeResultValueFail ("int error"); + EXPECT_FALSE (intResult.wasOk()); + EXPECT_TRUE (intResult.failed()); + EXPECT_EQ (intResult.getErrorMessage(), "int error"); + + ResultValue strResult = makeResultValueFail ("string error"); + EXPECT_FALSE (strResult.wasOk()); + EXPECT_EQ (strResult.getErrorMessage(), "string error"); +} + +TEST (ResultValueTests, MakeResultValueFailWorksWithReturnDeduction) +{ + auto makeFailure = []() -> ResultValue + { + return makeResultValueFail ("something went wrong"); + }; + + auto result = makeFailure(); + EXPECT_FALSE (result.wasOk()); + EXPECT_EQ (result.getErrorMessage(), "something went wrong"); +} + +TEST (ResultValueTests, MakeResultValueFailWithEmptyMessageUsesDefault) +{ + ResultValue result = makeResultValueFail (""); + EXPECT_FALSE (result.wasOk()); + EXPECT_EQ (result.getErrorMessage(), "Unknown Error"); +} + +TEST (ResultValueTests, MakeResultValueOkAndFailProduceEqualResults) +{ + ResultValue a = makeResultValueOk (7); + ResultValue b = ResultValue::ok (7); + EXPECT_EQ (a, b); + + ResultValue c = makeResultValueFail ("err"); + ResultValue d = ResultValue::fail ("err"); + EXPECT_EQ (c, d); +} diff --git a/tests/yup_gui.cpp b/tests/yup_gui.cpp index 31eb68a04..5467ea7a0 100644 --- a/tests/yup_gui.cpp +++ b/tests/yup_gui.cpp @@ -19,6 +19,7 @@ ============================================================================== */ +#include "yup_gui/yup_ApplicationTheme.cpp" #include "yup_gui/yup_ComboBox.cpp" #include "yup_gui/yup_Component.cpp" #include "yup_gui/yup_Desktop.cpp" diff --git a/tests/yup_gui/yup_ApplicationTheme.cpp b/tests/yup_gui/yup_ApplicationTheme.cpp new file mode 100644 index 000000000..7f4a57805 --- /dev/null +++ b/tests/yup_gui/yup_ApplicationTheme.cpp @@ -0,0 +1,167 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +// ============================================================================= + +class ApplicationThemeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + oldTheme = ApplicationTheme::getGlobalTheme(); + + theme = new ApplicationTheme(); + ApplicationTheme::setGlobalTheme (theme); + } + + void TearDown() override + { + ApplicationTheme::setGlobalTheme (oldTheme.get()); + theme = nullptr; + oldTheme = nullptr; + } + + ApplicationTheme::Ptr theme; + ApplicationTheme::Ptr oldTheme; +}; + +// ============================================================================= + +TEST_F (ApplicationThemeTest, FindColorReturnsNulloptWhenColorNotRegistered) +{ + auto result = ApplicationTheme::findColor (Identifier ("unknownColor")); + EXPECT_FALSE (result.has_value()); +} + +TEST_F (ApplicationThemeTest, FindColorReturnsRegisteredColor) +{ + const Color expected = Color::fromRGBA (255, 128, 0, 255); + theme->setColor (Identifier ("testColor"), expected); + + auto result = ApplicationTheme::findColor (Identifier ("testColor")); + ASSERT_TRUE (result.has_value()); + EXPECT_EQ (result.value(), expected); +} + +TEST_F (ApplicationThemeTest, SetColorsRegistersMultipleColors) +{ + const Identifier idA ("colorA"); + const Identifier idB ("colorB"); + const Color colorA = Color::fromRGBA (10, 20, 30, 255); + const Color colorB = Color::fromRGBA (40, 50, 60, 128); + + theme->setColors ({ { idA, colorA }, { idB, colorB } }); + + auto resultA = ApplicationTheme::findColor (idA); + auto resultB = ApplicationTheme::findColor (idB); + + ASSERT_TRUE (resultA.has_value()); + EXPECT_EQ (resultA.value(), colorA); + ASSERT_TRUE (resultB.has_value()); + EXPECT_EQ (resultB.value(), colorB); +} + +TEST_F (ApplicationThemeTest, SetColorOverwritesExistingColor) +{ + const Identifier id ("myColor"); + const Color first = Color::fromRGBA (1, 2, 3, 255); + const Color second = Color::fromRGBA (4, 5, 6, 255); + + theme->setColor (id, first); + theme->setColor (id, second); + + auto result = ApplicationTheme::findColor (id); + ASSERT_TRUE (result.has_value()); + EXPECT_EQ (result.value(), second); +} + +TEST_F (ApplicationThemeTest, FindColorUnregisteredIdDoesNotAffectOtherIds) +{ + const Identifier registered ("registered"); + const Identifier unregistered ("unregistered"); + const Color color = Color::fromRGBA (0, 0, 255, 255); + + theme->setColor (registered, color); + + EXPECT_TRUE (ApplicationTheme::findColor (registered).has_value()); + EXPECT_FALSE (ApplicationTheme::findColor (unregistered).has_value()); +} + +// ============================================================================= + +TEST_F (ApplicationThemeTest, FindMetricReturnsNulloptWhenMetricNotRegistered) +{ + auto result = ApplicationTheme::findMetric (Identifier ("unknownMetric")); + EXPECT_FALSE (result.has_value()); +} + +TEST_F (ApplicationThemeTest, FindMetricReturnsRegisteredValue) +{ + theme->setMetric (Identifier ("cornerRadius"), 8.0f); + + auto result = ApplicationTheme::findMetric (Identifier ("cornerRadius")); + ASSERT_TRUE (result.has_value()); + EXPECT_FLOAT_EQ (result.value(), 8.0f); +} + +TEST_F (ApplicationThemeTest, SetMetricOverwritesExistingValue) +{ + const Identifier id ("borderWidth"); + + theme->setMetric (id, 1.0f); + theme->setMetric (id, 3.5f); + + auto result = ApplicationTheme::findMetric (id); + ASSERT_TRUE (result.has_value()); + EXPECT_FLOAT_EQ (result.value(), 3.5f); +} + +TEST_F (ApplicationThemeTest, FindMetricUnregisteredIdDoesNotAffectOtherIds) +{ + const Identifier registered ("spacing"); + const Identifier unregistered ("padding"); + + theme->setMetric (registered, 4.0f); + + EXPECT_TRUE (ApplicationTheme::findMetric (registered).has_value()); + EXPECT_FALSE (ApplicationTheme::findMetric (unregistered).has_value()); +} + +TEST_F (ApplicationThemeTest, SetMetricAcceptsZeroAndNegativeValues) +{ + theme->setMetric (Identifier ("zeroMetric"), 0.0f); + theme->setMetric (Identifier ("negativeMetric"), -2.5f); + + auto zero = ApplicationTheme::findMetric (Identifier ("zeroMetric")); + auto negative = ApplicationTheme::findMetric (Identifier ("negativeMetric")); + + ASSERT_TRUE (zero.has_value()); + EXPECT_FLOAT_EQ (zero.value(), 0.0f); + + ASSERT_TRUE (negative.has_value()); + EXPECT_FLOAT_EQ (negative.value(), -2.5f); +}