From 5d3ab8e3171e4a6b9e178c852dbf1306eb780670 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Tue, 19 May 2026 17:20:18 +0200 Subject: [PATCH 01/14] More audio graph testing --- examples/audiograph/source/AudioGraphApp.h | 14 +- examples/audiograph/source/main.cpp | 1 + .../audiograph/source/nodes/LatencyNode.h | 245 ++++++++++++++++++ .../audiograph/source/nodes/NodeRegistry.h | 41 ++- .../source/nodes/SamplePlayerNode.h | 7 +- 5 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 examples/audiograph/source/nodes/LatencyNode.h diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h index 7f0647969..c7ae54528 100644 --- a/examples/audiograph/source/AudioGraphApp.h +++ b/examples/audiograph/source/AudioGraphApp.h @@ -411,7 +411,7 @@ class AudioGraphApp final continue; auto* proc = graph->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, props->identifier, proc); + auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); if (view != nullptr) { @@ -441,8 +441,9 @@ class AudioGraphApp final auto internalSubMenu = yup::PopupMenu::create(); internalSubMenu->addItem ("Oscillator", 1); internalSubMenu->addItem ("Gain", 2); - internalSubMenu->addItem ("Low Pass Filter", 3); - internalSubMenu->addItem ("Sample Player", 4); + internalSubMenu->addItem ("Latency", 3); + internalSubMenu->addItem ("Low Pass Filter", 4); + internalSubMenu->addItem ("Sample Player", 5); activeMenu = yup::PopupMenu::create (options); activeMenu->addSubMenu ("Internal Nodes", internalSubMenu); @@ -477,11 +478,12 @@ class AudioGraphApp final const yup::String internalIds[] = { NodeRegistry::oscillatorIdentifier, NodeRegistry::gainIdentifier, + NodeRegistry::latencyIdentifier, NodeRegistry::lpfIdentifier, NodeRegistry::samplePlayerIdentifier }; - if (selectedID >= 1 && selectedID <= 4) + if (selectedID >= 1 && selectedID <= 5) { addInternalNode (internalIds[selectedID - 1], pendingMenuCanvasPos); } @@ -524,7 +526,7 @@ class AudioGraphApp final graph->commitChanges(); auto* rawProc = graph->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, identifier, rawProc); + auto view = nodeRegistry.createView (nodeID, identifier, rawProc, graph.get()); if (view != nullptr) { graphComponent->addNodeView (nodeID, std::move (view), canvasPos); @@ -566,7 +568,7 @@ class AudioGraphApp final graph->commitChanges(); auto* rawProc = graph->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, props.identifier, rawProc); + auto view = nodeRegistry.createView (nodeID, props.identifier, rawProc, graph.get()); if (view != nullptr) { graphComponent->addNodeView (nodeID, std::move (view), canvasPos); diff --git a/examples/audiograph/source/main.cpp b/examples/audiograph/source/main.cpp index 7f7fc4f24..02afc4e9d 100644 --- a/examples/audiograph/source/main.cpp +++ b/examples/audiograph/source/main.cpp @@ -36,6 +36,7 @@ #include "nodes/OscillatorNode.h" #include "nodes/GainNode.h" +#include "nodes/LatencyNode.h" #include "nodes/LowPassFilterNode.h" #include "nodes/SamplePlayerNode.h" #include "nodes/PluginNodeView.h" diff --git a/examples/audiograph/source/nodes/LatencyNode.h b/examples/audiograph/source/nodes/LatencyNode.h new file mode 100644 index 000000000..ebbc80fdd --- /dev/null +++ b/examples/audiograph/source/nodes/LatencyNode.h @@ -0,0 +1,245 @@ +/* + ============================================================================== + + 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 LatencyProcessor final : public yup::AudioProcessor +{ +public: + LatencyProcessor() + : AudioProcessor ("Latency", + yup::AudioBusLayout ({ yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Input, 2) }, + { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) + { + setDelayMilliseconds (defaultDelayMilliseconds); + } + + void prepareToPlay (float newSampleRate, int maxBlockSize) override + { + sampleRate.store (yup::jmax (1.0f, newSampleRate), std::memory_order_relaxed); + + const int maxDelaySamples = delayMillisecondsToSamples (maximumDelayMilliseconds); + history.setSize (2, maxDelaySamples + yup::jmax (1, maxBlockSize) + 1); + history.clear(); + + writePosition = 0; + updateDelaySamples(); + } + + void releaseResources() override {} + + void flush() override + { + history.clear(); + writePosition = 0; + } + + void processBlock (yup::AudioBuffer& audioBuffer, yup::MidiBuffer&) override + { + const int currentDelaySamples = delaySamples.load (std::memory_order_relaxed); + + if (currentDelaySamples <= 0) + return; + + const int ringSize = history.getNumSamples(); + if (ringSize <= currentDelaySamples) + { + audioBuffer.clear(); + return; + } + + const int channels = yup::jmin (audioBuffer.getNumChannels(), history.getNumChannels()); + + for (int sample = 0; sample < audioBuffer.getNumSamples(); ++sample) + { + const int readPosition = (writePosition + ringSize - currentDelaySamples) % ringSize; + + for (int channel = 0; channel < channels; ++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.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& data) override + { + if (data.isEmpty()) + return yup::Result::ok(); + + yup::MemoryInputStream stream (data, false); + + const int version = stream.readInt(); + if (version != 1) + return yup::Result::fail ("Unsupported latency node state version"); + + setDelayMilliseconds (stream.readFloat()); + return yup::Result::ok(); + } + + yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + { + yup::MemoryOutputStream stream (data, false); + stream.writeInt (1); + stream.writeFloat (delayMilliseconds.load (std::memory_order_relaxed)); + stream.flush(); + + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + float getDelayMilliseconds() const noexcept + { + return delayMilliseconds.load (std::memory_order_relaxed); + } + + int getDelaySamples() const noexcept + { + return delaySamples.load (std::memory_order_relaxed); + } + + void setDelayMilliseconds (float newDelayMilliseconds) noexcept + { + delayMilliseconds.store (yup::jlimit (0.0f, maximumDelayMilliseconds, newDelayMilliseconds), std::memory_order_relaxed); + updateDelaySamples(); + } + +private: + static constexpr float defaultDelayMilliseconds = 100.0f; + static constexpr float maximumDelayMilliseconds = 1000.0f; + + int delayMillisecondsToSamples (float milliseconds) const noexcept + { + const auto sr = sampleRate.load (std::memory_order_relaxed); + return yup::roundToInt ((static_cast (milliseconds) * static_cast (sr)) / 1000.0); + } + + void updateDelaySamples() noexcept + { + delaySamples.store (delayMillisecondsToSamples (delayMilliseconds.load (std::memory_order_relaxed)), std::memory_order_relaxed); + } + + std::atomic sampleRate { 44100.0f }; + std::atomic delayMilliseconds { defaultDelayMilliseconds }; + std::atomic delaySamples { 0 }; + int writePosition = 0; + yup::AudioBuffer history; +}; + +//============================================================================== +class LatencyNodeView final : public yup::AudioGraphNodeView +{ +public: + LatencyNodeView (yup::AudioGraphNodeID nodeID, LatencyProcessor& processorIn, yup::AudioGraphProcessor* graphIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + , graph (graphIn) + , delaySlider (yup::Slider::LinearBarHorizontal) + { + NodeViewHelpers::configureParameterSlider (delaySlider, getPortKindColor (PortKind::parameter)); + delaySlider.setRange (0.0, 1000.0, 1.0); + delaySlider.setSkewFactorFromMidpoint (100.0); + delaySlider.setValue (processor.getDelayMilliseconds(), yup::dontSendNotification); + delaySlider.onValueChanged = [this] (double value) + { + processor.setDelayMilliseconds (static_cast (value)); + + if (! delaySlider.isCurrentlyBeingDragged()) + commitLatencyChange(); + + repaint(); + }; + delaySlider.onDragEnd = [this] (const yup::MouseEvent&) + { + commitLatencyChange(); + repaint(); + }; + addAndMakeVisible (delaySlider); + } + + yup::String getNodeTitle() const override { return "LATENCY"; } + + int getNumInputPorts() const override { return 1; } + + int getNumOutputPorts() const override { return 1; } + + int getPreferredWidth() const override { return 240; } + + yup::Color getNodeColor() const override { return yup::Color (0xffeab308); } + + yup::String getNodeSubtitle() const override + { + return yup::String (processor.getDelayMilliseconds(), 0) + " ms / " + yup::String (processor.getDelaySamples()) + " samples"; + } + + 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 { "Delay", yup::String (processor.getDelayMilliseconds(), 0) + " ms", getPortKindColor (PortKind::parameter), -1.0f, PortKind::parameter }; + } + + void resized() override + { + delaySlider.setBounds (NodeViewHelpers::getInlineSliderBounds (*this, getPreferredWidth())); + } + +private: + void commitLatencyChange() + { + if (graph != nullptr) + graph->commitChanges(); + } + + LatencyProcessor& processor; + yup::AudioGraphProcessor* graph = nullptr; + yup::Slider delaySlider; +}; diff --git a/examples/audiograph/source/nodes/NodeRegistry.h b/examples/audiograph/source/nodes/NodeRegistry.h index d8db05e23..bb3c31e8e 100644 --- a/examples/audiograph/source/nodes/NodeRegistry.h +++ b/examples/audiograph/source/nodes/NodeRegistry.h @@ -45,6 +45,9 @@ class NodeRegistry /** Stable factory key for the built-in gain node. */ static constexpr const char* gainIdentifier = "internal.gain"; + /** Stable factory key for the built-in latency node. */ + static constexpr const char* latencyIdentifier = "internal.latency"; + /** Stable factory key for the built-in low-pass filter node. */ static constexpr const char* lpfIdentifier = "internal.lpf"; @@ -64,7 +67,7 @@ class NodeRegistry //============================================================================== using ProcessorFactory = std::function> (const yup::AudioGraphNodeProperties&)>; - using ViewFactory = std::function (yup::AudioGraphNodeID, yup::AudioProcessor*)>; + using ViewFactory = std::function (yup::AudioGraphNodeID, yup::AudioProcessor*, yup::AudioGraphProcessor*)>; //============================================================================== struct Entry @@ -88,7 +91,7 @@ class NodeRegistry { return yup::makeResultValueOk (std::make_unique()); }, - [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr { auto* osc = dynamic_cast (proc); if (osc == nullptr) @@ -103,7 +106,7 @@ class NodeRegistry { return yup::makeResultValueOk (std::make_unique()); }, - [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr { auto* gain = dynamic_cast (proc); if (gain == nullptr) @@ -113,12 +116,27 @@ class NodeRegistry } }; + entries[latencyIdentifier] = { + [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> + { + return yup::makeResultValueOk (std::make_unique()); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor* graph) -> std::unique_ptr + { + auto* latency = dynamic_cast (proc); + if (latency == nullptr) + return nullptr; + + return std::make_unique (nodeID, *latency, graph); + } + }; + entries[lpfIdentifier] = { [] (const yup::AudioGraphNodeProperties&) -> yup::ResultValue> { return yup::makeResultValueOk (std::make_unique()); }, - [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr { auto* lpf = dynamic_cast (proc); if (lpf == nullptr) @@ -133,7 +151,7 @@ class NodeRegistry { return yup::makeResultValueOk (std::make_unique()); }, - [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) -> std::unique_ptr + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr { auto* samplePlayer = dynamic_cast (proc); if (samplePlayer == nullptr) @@ -168,7 +186,7 @@ class NodeRegistry return loadPluginFromProperties (props); }; - auto viewFactory = [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc) + auto viewFactory = [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr { auto* instance = dynamic_cast (proc); @@ -316,23 +334,26 @@ class NodeRegistry @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. + @param graph The graph that owns the node, used by views that need to + commit metadata-affecting parameter changes. @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) + yup::AudioProcessor* proc, + yup::AudioGraphProcessor* graph = nullptr) { auto it = entries.find (identifier); if (it == entries.end()) return nullptr; - return it->second.createView (nodeID, proc); + return it->second.createView (nodeID, proc, graph); } //============================================================================== std::vector getInternalNodeIdentifiers() const { - return { oscillatorIdentifier, gainIdentifier, lpfIdentifier, samplePlayerIdentifier }; + return { oscillatorIdentifier, gainIdentifier, latencyIdentifier, lpfIdentifier, samplePlayerIdentifier }; } //============================================================================== @@ -349,6 +370,8 @@ class NodeRegistry return "Oscillator"; if (id == gainIdentifier) return "Gain"; + if (id == latencyIdentifier) + return "Latency"; if (id == lpfIdentifier) return "Low Pass Filter"; if (id == samplePlayerIdentifier) diff --git a/examples/audiograph/source/nodes/SamplePlayerNode.h b/examples/audiograph/source/nodes/SamplePlayerNode.h index c22620e9c..aa104993f 100644 --- a/examples/audiograph/source/nodes/SamplePlayerNode.h +++ b/examples/audiograph/source/nodes/SamplePlayerNode.h @@ -221,6 +221,7 @@ class SampleWaveformComponent final : processor (processorIn) , thumbnail (waveformColor) , accentColor (waveformColor) + , playheadColor (yup::Colors::yellow) { thumbnail.addListener (this); setOpaque (false); @@ -285,13 +286,13 @@ class SampleWaveformComponent final processor.getPlaybackPositionSamples() / static_cast (thumbnail.getTotalSamples())))); - g.setFillColor (accentColor.withAlpha (0.95f)); + g.setFillColor (playheadColor.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.setFillColor (playheadColor.withAlpha (0.80f)); g.fillRect (progressBounds.withWidth (progressBounds.getWidth() * static_cast (thumbnail.getProgress()))); } } @@ -305,7 +306,7 @@ class SampleWaveformComponent final SamplePlayerProcessor& processor; SampleWaveformThumbnail thumbnail; - yup::Color accentColor; + yup::Color accentColor, playheadColor; std::shared_ptr currentSample; }; From ff8b006bedde1b8359a41ade503d62884f80f606 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 20 May 2026 16:19:20 +0200 Subject: [PATCH 02/14] Fix sliders --- examples/audiograph/source/AudioGraphApp.h | 26 +++---- .../audiograph/source/nodes/NodeRegistry.h | 23 ++++-- examples/graphics/source/examples/Audio.h | 2 +- .../graphics/source/examples/AudioFileDemo.h | 4 +- .../graphics/source/examples/FilterDemo.h | 18 ++--- .../graphics/source/examples/SliderDemo.h | 2 +- .../source/examples/SpectrumAnalyzer.h | 12 ++-- .../themes/theme_v1/yup_ThemeVersion1.cpp | 70 ++++++++----------- modules/yup_gui/widgets/yup_Slider.h | 8 +-- 9 files changed, 80 insertions(+), 85 deletions(-) diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h index c7ae54528..dd2e5eba9 100644 --- a/examples/audiograph/source/AudioGraphApp.h +++ b/examples/audiograph/source/AudioGraphApp.h @@ -439,11 +439,13 @@ class AudioGraphApp final .withPosition (screenPos, yup::Justification::topLeft); auto internalSubMenu = yup::PopupMenu::create(); - internalSubMenu->addItem ("Oscillator", 1); - internalSubMenu->addItem ("Gain", 2); - internalSubMenu->addItem ("Latency", 3); - internalSubMenu->addItem ("Low Pass Filter", 4); - internalSubMenu->addItem ("Sample Player", 5); + + auto internalNodes = nodeRegistry.getInternalNodeIdentifiers(); + for (const auto& identifier : internalNodes) + { + const auto displayName = NodeRegistry::identifierToDisplayName (identifier); + internalSubMenu->addItem (displayName, static_cast (internalSubMenu->getNumItems() + 1)); + } activeMenu = yup::PopupMenu::create (options); activeMenu->addSubMenu ("Internal Nodes", internalSubMenu); @@ -470,22 +472,14 @@ class AudioGraphApp final } #endif - activeMenu->show ([this] (int selectedID) + activeMenu->show ([this, internalNodes = std::move (internalNodes)] (int selectedID) { if (selectedID <= 0) return; - const yup::String internalIds[] = { - NodeRegistry::oscillatorIdentifier, - NodeRegistry::gainIdentifier, - NodeRegistry::latencyIdentifier, - NodeRegistry::lpfIdentifier, - NodeRegistry::samplePlayerIdentifier - }; - - if (selectedID >= 1 && selectedID <= 5) + if (selectedID >= 1 && selectedID <= static_cast (internalNodes.size())) { - addInternalNode (internalIds[selectedID - 1], pendingMenuCanvasPos); + addInternalNode (internalNodes[selectedID - 1], pendingMenuCanvasPos); } #if YUP_DESKTOP else if (selectedID >= 100) diff --git a/examples/audiograph/source/nodes/NodeRegistry.h b/examples/audiograph/source/nodes/NodeRegistry.h index bb3c31e8e..954ce5c49 100644 --- a/examples/audiograph/source/nodes/NodeRegistry.h +++ b/examples/audiograph/source/nodes/NodeRegistry.h @@ -54,6 +54,9 @@ class NodeRegistry /** Stable factory key for the built-in looping sample player node. */ static constexpr const char* samplePlayerIdentifier = "internal.samplePlayer"; + /** Stable factory key for Unknown plugin nodes. */ + static constexpr const char* pluginUnknownIdentifier = "plugin.unknown"; + #if YUP_DESKTOP /** Stable factory key for VST3 plugin nodes. */ static constexpr const char* pluginVst3Identifier = "plugin.vst3"; @@ -231,12 +234,15 @@ class NodeRegistry { case yup::AudioPluginFormatType::vst3: return pluginVst3Identifier; + case yup::AudioPluginFormatType::clap: return pluginClapIdentifier; + case yup::AudioPluginFormatType::audioUnit: return pluginAuIdentifier; + default: - return "plugin.unknown"; + return pluginUnknownIdentifier; } } @@ -353,7 +359,13 @@ class NodeRegistry //============================================================================== std::vector getInternalNodeIdentifiers() const { - return { oscillatorIdentifier, gainIdentifier, latencyIdentifier, lpfIdentifier, samplePlayerIdentifier }; + return { + oscillatorIdentifier, + gainIdentifier, + latencyIdentifier, + lpfIdentifier, + samplePlayerIdentifier + }; } //============================================================================== @@ -368,12 +380,16 @@ class NodeRegistry { if (id == oscillatorIdentifier) return "Oscillator"; + if (id == gainIdentifier) return "Gain"; + if (id == latencyIdentifier) return "Latency"; + if (id == lpfIdentifier) return "Low Pass Filter"; + if (id == samplePlayerIdentifier) return "Sample Player"; @@ -397,8 +413,7 @@ class NodeRegistry @returns A ResultValue containing the loaded AudioPluginInstance on success, or an error message on failure. */ - yup::ResultValue> loadPluginFromProperties ( - const yup::AudioGraphNodeProperties& props) + yup::ResultValue> loadPluginFromProperties (const yup::AudioGraphNodeProperties& props) { if (pluginScanner == nullptr) return yup::makeResultValueFail ("No plugin scanner available"); diff --git a/examples/graphics/source/examples/Audio.h b/examples/graphics/source/examples/Audio.h index 30e8b1c58..3880da603 100644 --- a/examples/graphics/source/examples/Audio.h +++ b/examples/graphics/source/examples/Audio.h @@ -404,7 +404,7 @@ class AudioExample addAndMakeVisible (*clearButton); // Add volume control - volumeSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Volume"); + volumeSlider = std::make_unique (yup::Slider::LinearHorizontal, "Volume"); // Configure slider range and default value volumeSlider->setRange ({ 0.0f, 1.0f }); diff --git a/examples/graphics/source/examples/AudioFileDemo.h b/examples/graphics/source/examples/AudioFileDemo.h index 227da802f..51f3c65a9 100644 --- a/examples/graphics/source/examples/AudioFileDemo.h +++ b/examples/graphics/source/examples/AudioFileDemo.h @@ -1050,8 +1050,8 @@ class AudioFileDemo : public yup::Component yup::Label timeLabel; yup::Label pitchLabel; - yup::Slider timeStretchSlider { yup::Slider::LinearBarHorizontal, "Time Stretch" }; - yup::Slider pitchShiftSlider { yup::Slider::LinearBarHorizontal, "Pitch Shift" }; + yup::Slider timeStretchSlider { yup::Slider::LinearHorizontal, "Time Stretch" }; + yup::Slider pitchShiftSlider { yup::Slider::LinearHorizontal, "Pitch Shift" }; yup::Label infoLabel; yup::Label statusLabel; diff --git a/examples/graphics/source/examples/FilterDemo.h b/examples/graphics/source/examples/FilterDemo.h index 488fc7483..2eedbaf51 100644 --- a/examples/graphics/source/examples/FilterDemo.h +++ b/examples/graphics/source/examples/FilterDemo.h @@ -978,7 +978,7 @@ class FilterDemo addAndMakeVisible (*responseTypeCombo); // FIR-specific controls - firCoefficientsSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "FIR Length"); + firCoefficientsSlider = std::make_unique (yup::Slider::LinearHorizontal, "FIR Length"); firCoefficientsSlider->setRange ({ 16.0, 256.0 }); firCoefficientsSlider->setValue (64.0); firCoefficientsSlider->onValueChanged = [this] (double value) @@ -1003,7 +1003,7 @@ class FilterDemo addAndMakeVisible (*firWindowCombo); // FIR window parameter control (for adjustable windows like Kaiser and Rakshit-Ullah) - firWindowParameterSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Window Parameter"); + firWindowParameterSlider = std::make_unique (yup::Slider::LinearHorizontal, "Window Parameter"); firWindowParameterSlider->setRange ({ 0.0005, 10.0 }); firWindowParameterSlider->setSkewFactorFromMidpoint (1.0); firWindowParameterSlider->setValue (1.0); @@ -1014,7 +1014,7 @@ class FilterDemo addAndMakeVisible (*firWindowParameterSlider); // Parameter controls with smoothed parameter updates - frequencySlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Frequency"); + frequencySlider = std::make_unique (yup::Slider::LinearHorizontal, "Frequency"); frequencySlider->setRange ({ 20.0, 20000.0 }); frequencySlider->setSkewFactorFromMidpoint (1000.0); // 1kHz at midpoint frequencySlider->setValue (1000.0); @@ -1025,7 +1025,7 @@ class FilterDemo }; addAndMakeVisible (*frequencySlider); - frequency2Slider = std::make_unique (yup::Slider::LinearBarHorizontal, "Frequency 2"); + frequency2Slider = std::make_unique (yup::Slider::LinearHorizontal, "Frequency 2"); frequency2Slider->setRange ({ 20.0, 20000.0 }); frequency2Slider->setSkewFactorFromMidpoint (2000.0); // 2kHz at midpoint frequency2Slider->setValue (2000.0); @@ -1036,7 +1036,7 @@ class FilterDemo }; addAndMakeVisible (*frequency2Slider); - qSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Q / Resonance"); + qSlider = std::make_unique (yup::Slider::LinearHorizontal, "Q / Resonance"); qSlider->setRange ({ 0.0, 1.0 }); qSlider->setSkewFactorFromMidpoint (0.3); // More resolution at lower Q values qSlider->setValue (0.0); @@ -1047,7 +1047,7 @@ class FilterDemo }; addAndMakeVisible (*qSlider); - gainSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Gain (dB)"); + gainSlider = std::make_unique (yup::Slider::LinearHorizontal, "Gain (dB)"); gainSlider->setRange ({ -48.0, 20.0 }); gainSlider->setSkewFactorFromMidpoint (0.0); // 0 dB at midpoint gainSlider->setValue (0.0); @@ -1058,7 +1058,7 @@ class FilterDemo }; addAndMakeVisible (*gainSlider); - orderSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Order"); + orderSlider = std::make_unique (yup::Slider::LinearHorizontal, "Order"); orderSlider->setRange ({ 2.0, 16.0 }); orderSlider->setValue (2.0); orderSlider->onValueChanged = [this] (double value) @@ -1069,7 +1069,7 @@ class FilterDemo addAndMakeVisible (*orderSlider); // Noise gain control - noiseGainSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Noise Level"); + noiseGainSlider = std::make_unique (yup::Slider::LinearHorizontal, "Noise Level"); noiseGainSlider->setRange ({ 0.0, 1.0 }); noiseGainSlider->setValue (0.1); noiseGainSlider->onValueChanged = [this] (double value) @@ -1079,7 +1079,7 @@ class FilterDemo addAndMakeVisible (*noiseGainSlider); // Output gain control - outputGainSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Output Level"); + outputGainSlider = std::make_unique (yup::Slider::LinearHorizontal, "Output Level"); outputGainSlider->setRange ({ 0.0, 1.0 }); outputGainSlider->setValue (0.5); outputGainSlider->onValueChanged = [this] (double value) diff --git a/examples/graphics/source/examples/SliderDemo.h b/examples/graphics/source/examples/SliderDemo.h index b731ce1ec..5381c115b 100644 --- a/examples/graphics/source/examples/SliderDemo.h +++ b/examples/graphics/source/examples/SliderDemo.h @@ -65,7 +65,7 @@ class SliderDemo : public yup::Component addAndMakeVisible (rotarySlider.get()); // Bar Horizontal Slider - barHorizontalSlider = std::make_unique (yup::Slider::LinearBarHorizontal); + barHorizontalSlider = std::make_unique (yup::Slider::LinearHorizontal); barHorizontalSlider->setRange (0.0, 100.0); barHorizontalSlider->setValue (75.0); barHorizontalSlider->onValueChanged = [this] (double value) diff --git a/examples/graphics/source/examples/SpectrumAnalyzer.h b/examples/graphics/source/examples/SpectrumAnalyzer.h index 36e663f0d..2cc874d5c 100644 --- a/examples/graphics/source/examples/SpectrumAnalyzer.h +++ b/examples/graphics/source/examples/SpectrumAnalyzer.h @@ -386,7 +386,7 @@ class SpectrumAnalyzerDemo addAndMakeVisible (*signalTypeCombo); // Frequency control - frequencySlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Frequency"); + frequencySlider = std::make_unique (yup::Slider::LinearHorizontal, "Frequency"); frequencySlider->setRange ({ 20.0, 22000.0 }); frequencySlider->setSkewFactorFromMidpoint (440.0); frequencySlider->setValue (440.0); @@ -398,7 +398,7 @@ class SpectrumAnalyzerDemo addAndMakeVisible (*frequencySlider); // Amplitude control - amplitudeSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Amplitude"); + amplitudeSlider = std::make_unique (yup::Slider::LinearHorizontal, "Amplitude"); amplitudeSlider->setRange ({ 0.0, 1.0 }); amplitudeSlider->setValue (0.5); amplitudeSlider->onValueChanged = [this] (double value) @@ -409,7 +409,7 @@ class SpectrumAnalyzerDemo addAndMakeVisible (*amplitudeSlider); // Sweep duration control - sweepDurationSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Sweep Duration"); + sweepDurationSlider = std::make_unique (yup::Slider::LinearHorizontal, "Sweep Duration"); sweepDurationSlider->setRange ({ 1.0, 60.0 }); sweepDurationSlider->setValue (10.0); sweepDurationSlider->onValueChanged = [this] (double value) @@ -463,7 +463,7 @@ class SpectrumAnalyzerDemo addAndMakeVisible (*displayTypeCombo); // Release control - releaseSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Release"); + releaseSlider = std::make_unique (yup::Slider::LinearHorizontal, "Release"); releaseSlider->setRange ({ 0.0, 5.0 }); releaseSlider->setValue (1.0); releaseSlider->onValueChanged = [this] (double value) @@ -473,7 +473,7 @@ class SpectrumAnalyzerDemo addAndMakeVisible (*releaseSlider); // Overlap control for responsiveness - overlapSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Overlap"); + overlapSlider = std::make_unique (yup::Slider::LinearHorizontal, "Overlap"); overlapSlider->setRange ({ 0.0, 0.95 }); overlapSlider->setValue (0.75); overlapSlider->onValueChanged = [this] (double value) @@ -483,7 +483,7 @@ class SpectrumAnalyzerDemo addAndMakeVisible (*overlapSlider); // Smoothing time control - smoothingSlider = std::make_unique (yup::Slider::LinearBarHorizontal, "Smoothing"); + smoothingSlider = std::make_unique (yup::Slider::LinearHorizontal, "Smoothing"); smoothingSlider->setRange ({ 0.001, 0.5 }); smoothingSlider->setValue (0.05); smoothingSlider->onValueChanged = [this] (double value) diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index d319d1503..6a1f400d1 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -124,6 +124,7 @@ void paintRotarySlider (Graphics& g, const ApplicationTheme& theme, const Slider g.setStrokeWidth (slider.proportionOfWidth (0.075f)); g.strokePath (foregroundArc); + /* if (slider.hasKeyboardFocus()) { Path focusPath; @@ -133,6 +134,7 @@ void paintRotarySlider (Graphics& g, const ApplicationTheme& theme, const Slider g.setStrokeWidth (2.0f); g.strokePath (focusPath); } + */ } void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider& slider, Rectangle sliderBounds, Rectangle thumbBounds, bool isHorizontal, float sliderValue, bool isMouseOver, bool isMouseDown) @@ -140,7 +142,7 @@ void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider const auto colors = getSliderColors (theme, slider); // Draw track background - g.setFillColor (colors.background); + g.setFillColor (colors.track); if (isHorizontal) g.fillRoundedRect (sliderBounds.getX(), sliderBounds.getCenterY() - 2.0f, sliderBounds.getWidth(), 4.0f, 2.0f); else @@ -150,7 +152,7 @@ void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider const auto sliderType = slider.getSliderType(); if (sliderType == Slider::LinearBarHorizontal || sliderType == Slider::LinearBarVertical) { - g.setFillColor (colors.track); + auto thumbColor = slider.isMouseOver() ? colors.thumbOver : colors.thumb; if (isHorizontal) g.fillRoundedRect (sliderBounds.getX(), sliderBounds.getCenterY() - 2.0f, sliderValue * sliderBounds.getWidth(), 4.0f, 2.0f); else @@ -166,6 +168,7 @@ void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider g.fillEllipse (thumbBounds); } + /* // Draw focus outline if needed if (slider.hasKeyboardFocus()) { @@ -173,6 +176,7 @@ void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider g.setStrokeWidth (2.0f); g.strokeRoundedRect (slider.getLocalBounds().reduced (2), 2.0f); } + */ } void paintTwoValueSlider (Graphics& g, const ApplicationTheme& theme, const Slider& slider, Rectangle sliderBounds, Rectangle minThumbBounds, Rectangle maxThumbBounds, bool isHorizontal, float minValue, float maxValue, bool isMouseOverMinThumb, bool isMouseOverMaxThumb, bool isMouseDown) @@ -209,6 +213,7 @@ void paintTwoValueSlider (Graphics& g, const ApplicationTheme& theme, const Slid g.setFillColor (isMouseDown ? colors.thumbDown : (isMouseOverMaxThumb ? colors.thumbOver : colors.thumb)); g.fillEllipse (maxThumbBounds); + /* // Draw focus outline if needed if (slider.hasKeyboardFocus()) { @@ -216,6 +221,7 @@ void paintTwoValueSlider (Graphics& g, const ApplicationTheme& theme, const Slid g.setStrokeWidth (2.0f); g.strokeRoundedRect (slider.getLocalBounds().reduced (2), 2.0f); } + */ } void paintSlider (Graphics& g, const ApplicationTheme& theme, const Slider& s) @@ -1042,10 +1048,8 @@ void paintAudioGraphComponent (Graphics& g, const ApplicationTheme&, const Audio { 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)); + 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(); @@ -1083,10 +1087,8 @@ void paintAudioGraphPort (Graphics& g, 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)); + 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)); @@ -1112,22 +1114,14 @@ void paintAudioGraphNodeView (Graphics& g, const ApplicationTheme& theme, const 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)); + 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); @@ -1391,22 +1385,14 @@ void paintKMeter (Graphics& g, const ApplicationTheme& theme, const KMeterCompon return; // Get colors from theme - const auto backgroundColor = meter.findColor (KMeterComponent::Style::backgroundColorId) - .value_or (Color (0xff1a1a1a)); - const auto greenColor = meter.findColor (KMeterComponent::Style::greenZoneColorId) - .value_or (Color (0xff00cc00)); - const auto amberColor = meter.findColor (KMeterComponent::Style::amberZoneColorId) - .value_or (Color (0xffffaa00)); - const auto redColor = meter.findColor (KMeterComponent::Style::redZoneColorId) - .value_or (Color (0xffcc0000)); - const auto averageColor = meter.findColor (KMeterComponent::Style::averageLevelColorId) - .value_or (Color (0xccffffff)); - const auto peakColor = meter.findColor (KMeterComponent::Style::peakLevelColorId) - .value_or (Color (0xffffffff)); - const auto peakClipColor = meter.findColor (KMeterComponent::Style::peakLevelClipColorId) - .value_or (Color (0xffff0000)); - const auto peakHoldColor = meter.findColor (KMeterComponent::Style::peakHoldColorId) - .value_or (Color (0xffffff00)); + const auto backgroundColor = meter.findColor (KMeterComponent::Style::backgroundColorId).value_or (Color (0xff1a1a1a)); + const auto greenColor = meter.findColor (KMeterComponent::Style::greenZoneColorId).value_or (Color (0xff00cc00)); + const auto amberColor = meter.findColor (KMeterComponent::Style::amberZoneColorId).value_or (Color (0xffffaa00)); + const auto redColor = meter.findColor (KMeterComponent::Style::redZoneColorId).value_or (Color (0xffcc0000)); + const auto averageColor = meter.findColor (KMeterComponent::Style::averageLevelColorId).value_or (Color (0xccffffff)); + const auto peakColor = meter.findColor (KMeterComponent::Style::peakLevelColorId).value_or (Color (0xffffffff)); + const auto peakClipColor = meter.findColor (KMeterComponent::Style::peakLevelClipColorId).value_or (Color (0xffff0000)); + const auto peakHoldColor = meter.findColor (KMeterComponent::Style::peakHoldColorId).value_or (Color (0xffffff00)); // Draw background with subtle depth { diff --git a/modules/yup_gui/widgets/yup_Slider.h b/modules/yup_gui/widgets/yup_Slider.h index 3eebeee4d..b0768160f 100644 --- a/modules/yup_gui/widgets/yup_Slider.h +++ b/modules/yup_gui/widgets/yup_Slider.h @@ -266,10 +266,10 @@ class YUP_API Slider : public Component struct Style { static const Identifier backgroundColorId; /**< Background color for slider track/circle */ - static const Identifier trackColorId; /**< Color for active track or value indicator */ - static const Identifier thumbColorId; /**< Color for slider thumb/knob */ - static const Identifier thumbOverColorId; /**< Color for thumb when mouse is over */ - static const Identifier thumbDownColorId; /**< Color for thumb when pressed */ + static const Identifier trackColorId; /**< Color for active track */ + static const Identifier thumbColorId; /**< Color for slider thumb/knob or value indicator */ + static const Identifier thumbOverColorId; /**< Color for thumb/knob or value indicator when mouse is over */ + static const Identifier thumbDownColorId; /**< Color for thumb/knob or value indicator when pressed */ static const Identifier textColorId; /**< Color for text labels */ }; From c6ed47966d99b4d716f993b98096682f7e3fbf99 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 20 May 2026 18:05:47 +0200 Subject: [PATCH 03/14] Subgraph support --- examples/audiograph/source/AudioGraphApp.h | 361 ++++++++++++++--- examples/audiograph/source/main.cpp | 8 - .../source/nodes/GraphEndpointNodeViews.h | 27 ++ .../source/nodes/GraphInputNodeView.h | 73 ++++ .../source/nodes/GraphOutputNodeView.h | 73 ++++ .../audiograph/source/nodes/NodeRegistry.h | 40 +- .../source/nodes/SoundCardInputNodeView.h | 53 +++ .../source/nodes/SoundCardOutputNodeView.h | 53 +++ .../audiograph/source/nodes/SubgraphNode.h | 373 ++++++++++++++++++ .../source/ui/AudioGraphEditorPanel.h | 272 +++++++++++++ .../source/ui/SubgraphEditorWindow.h | 108 +++++ .../graph/yup_AudioGraphProcessor.cpp | 96 +++++ .../graph/yup_AudioGraphProcessor.h | 12 + .../yup_AudioGraphProcessor.cpp | 66 ++++ 14 files changed, 1538 insertions(+), 77 deletions(-) create mode 100644 examples/audiograph/source/nodes/GraphEndpointNodeViews.h create mode 100644 examples/audiograph/source/nodes/GraphInputNodeView.h create mode 100644 examples/audiograph/source/nodes/GraphOutputNodeView.h create mode 100644 examples/audiograph/source/nodes/SoundCardInputNodeView.h create mode 100644 examples/audiograph/source/nodes/SoundCardOutputNodeView.h create mode 100644 examples/audiograph/source/nodes/SubgraphNode.h create mode 100644 examples/audiograph/source/ui/AudioGraphEditorPanel.h create mode 100644 examples/audiograph/source/ui/SubgraphEditorWindow.h diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h index dd2e5eba9..d4c5c4494 100644 --- a/examples/audiograph/source/AudioGraphApp.h +++ b/examples/audiograph/source/AudioGraphApp.h @@ -21,64 +21,18 @@ #pragma once +#include #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 }; - } -}; +#include "nodes/GraphEndpointNodeViews.h" +#include "nodes/NodeRegistry.h" +#include "nodes/SubgraphNode.h" +#include "ui/AudioGraphEditorPanel.h" +#include "ui/PluginEditorWindow.h" +#include "ui/SubgraphEditorWindow.h" //============================================================================== @@ -124,7 +78,7 @@ class AudioGraphApp final }; graphComponent->onNodeDoubleClicked = [this] (yup::AudioGraphNodeID id) { - openPluginEditor (id); + openNodeEditor (graph, id); }; graphComponent->addListener (this); addAndMakeVisible (*graphComponent); @@ -144,6 +98,7 @@ class AudioGraphApp final #endif closePluginEditor(); + closeAllSubgraphEditors(); if (audioCallbackRegistered) { @@ -293,6 +248,7 @@ class AudioGraphApp final void resetToEmptyGraph() { closePluginEditor(); + closeAllSubgraphEditors(); for (auto& [nodeID, _] : loadedNodes) graphComponent->removeNodeView (nodeID); @@ -386,6 +342,7 @@ class AudioGraphApp final void loadGraphFromMemory (const yup::MemoryBlock& mb, const yup::File& file) { closePluginEditor(); + closeAllSubgraphEditors(); for (auto& [nodeID, _] : loadedNodes) graphComponent->removeNodeView (nodeID); @@ -447,8 +404,15 @@ class AudioGraphApp final internalSubMenu->addItem (displayName, static_cast (internalSubMenu->getNumItems() + 1)); } + auto subgraphSubMenu = yup::PopupMenu::create(); + subgraphSubMenu->addItem ("Mono Subgraph", 10); + subgraphSubMenu->addItem ("Stereo Subgraph", 11); + subgraphSubMenu->addItem ("Mono Subgraph + MIDI", 12); + subgraphSubMenu->addItem ("Stereo Subgraph + MIDI", 13); + activeMenu = yup::PopupMenu::create (options); activeMenu->addSubMenu ("Internal Nodes", internalSubMenu); + activeMenu->addSubMenu ("Subgraphs", subgraphSubMenu); #if YUP_DESKTOP const auto& plugins = nodeRegistry.getDiscoveredPlugins(); @@ -481,6 +445,17 @@ class AudioGraphApp final { addInternalNode (internalNodes[selectedID - 1], pendingMenuCanvasPos); } + else if (selectedID >= 10 && selectedID <= 13) + { + const auto preset = selectedID == 10 ? SubgraphConfig::mono + : selectedID == 11 ? SubgraphConfig::stereo + : selectedID == 12 ? SubgraphConfig::monoMidi + : SubgraphConfig::stereoMidi; + const auto config = SubgraphConfig::fromPreset (preset); + addInternalNode (NodeRegistry::subgraphIdentifier, + pendingMenuCanvasPos, + SubgraphConfig::toCreationData (config)); + } #if YUP_DESKTOP else if (selectedID >= 100) { @@ -494,13 +469,18 @@ class AudioGraphApp final }); } - void addInternalNode (const yup::String& identifier, yup::Point canvasPos) + void addInternalNode (const yup::String& identifier, + yup::Point canvasPos, + yup::MemoryBlock creationData = {}) { yup::AudioGraphNodeProperties props; props.identifier = identifier; - props.name = NodeRegistry::identifierToDisplayName (identifier); + props.name = identifier == NodeRegistry::subgraphIdentifier + ? SubgraphConfig::fromCreationData (creationData).getDisplayName() + : NodeRegistry::identifierToDisplayName (identifier); props.positionX = canvasPos.getX(); props.positionY = canvasPos.getY(); + props.creationData = std::move (creationData); auto processorResult = nodeRegistry.makeProcessorFactory() (props); @@ -575,10 +555,10 @@ class AudioGraphApp final void removeNode (yup::AudioGraphNodeID nodeID) { - if (activePluginEditorNodeID == nodeID) - { + if (activePluginEditorGraph == graph.get() && activePluginEditorNodeID == nodeID) closePluginEditor(); - } + + closeSubgraphEditorForNode (graph, nodeID); graphComponent->removeNodeView (nodeID); graph->removeNode (nodeID); @@ -588,15 +568,30 @@ class AudioGraphApp final //============================================================================== - void openPluginEditor (yup::AudioGraphNodeID nodeID) + void openNodeEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) + { + if (ownerGraph == nullptr) + return; + + auto* proc = ownerGraph->getNodeProcessor (nodeID); + if (auto* subgraph = dynamic_cast (proc)) + { + openSubgraphEditor (ownerGraph, nodeID, *subgraph); + return; + } + + openPluginEditor (ownerGraph, nodeID); + } + + void openPluginEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) { - if (pluginEditorWindow != nullptr && activePluginEditorNodeID == nodeID) + if (pluginEditorWindow != nullptr && activePluginEditorGraph == ownerGraph.get() && activePluginEditorNodeID == nodeID) { pluginEditorWindow->toFront (true); return; } - auto* proc = graph->getNodeProcessor (nodeID); + auto* proc = ownerGraph->getNodeProcessor (nodeID); if (proc == nullptr || ! proc->hasEditor()) return; @@ -605,6 +600,7 @@ class AudioGraphApp final return; closePluginEditor(); + activePluginEditorGraph = ownerGraph.get(); activePluginEditorNodeID = nodeID; const auto preferredSize = editor->getPreferredSize(); @@ -628,9 +624,248 @@ class AudioGraphApp final void closePluginEditor() { pluginEditorWindow.reset(); + activePluginEditorGraph = nullptr; activePluginEditorNodeID = yup::AudioGraphNodeID::invalid(); } + struct SubgraphEditorRecord + { + const yup::AudioGraphProcessor* ownerGraphRaw = nullptr; + yup::AudioGraphNodeID nodeID; + std::shared_ptr ownerGraph; + std::unique_ptr window; + }; + + std::unique_ptr createSubgraphPanel (std::shared_ptr childGraph) + { + auto panel = std::make_unique (std::move (childGraph), nodeRegistry, "parent graph"); + + panel->onNodeDoubleClicked = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) + { + openNodeEditor (editor.getGraph(), nodeID); + }; + + panel->onNodeWillBeRemoved = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) + { + if (activePluginEditorGraph == editor.getGraph().get() && activePluginEditorNodeID == nodeID) + closePluginEditor(); + + closeSubgraphEditorForNode (editor.getGraph(), nodeID); + }; + +#if YUP_DESKTOP + panel->getDiscoveredPlugins = [this]() -> const std::vector& + { + return nodeRegistry.getDiscoveredPlugins(); + }; + panel->onPluginSelected = [this] (AudioGraphEditorPanel& editor, + const yup::AudioPluginDescription& desc, + yup::Point canvasPos) + { + addPluginNodeToPanel (editor, desc, canvasPos); + }; +#endif + + return panel; + } + + void openSubgraphEditor (std::shared_ptr ownerGraph, + yup::AudioGraphNodeID nodeID, + SubgraphProcessor& processor) + { + if (auto* record = findSubgraphEditorRecord (ownerGraph.get(), nodeID)) + { + record->window->toFront (true); + return; + } + + auto childGraph = processor.getGraph(); + auto panel = createSubgraphPanel (childGraph); + + yup::WeakReference weakThis (this); + auto window = std::make_unique ( + processor.getConfig().getDisplayName(), + std::move (panel), + processor.getConfig(), + [weakThis, ownerGraph, nodeID] (int presetID) + { + if (auto* self = weakThis.get()) + self->reconfigureSubgraph (ownerGraph, nodeID, SubgraphConfig::fromPreset (presetID)); + }, + [weakThis, ownerGraph, nodeID] + { + if (auto* self = weakThis.get()) + self->closeSubgraphEditorForNode (ownerGraph, nodeID); + }); + + subgraphEditorWindows.push_back ({ ownerGraph.get(), nodeID, ownerGraph, std::move (window) }); + auto& record = subgraphEditorWindows.back(); + record.window->centreWithSize ({ 900, 620 }); + record.window->setVisible (true); + record.window->toFront (true); + } + + void reconfigureSubgraph (std::shared_ptr ownerGraph, + yup::AudioGraphNodeID nodeID, + const SubgraphConfig& config) + { + if (ownerGraph == nullptr) + return; + + auto* oldProcessor = dynamic_cast (ownerGraph->getNodeProcessor (nodeID)); + if (oldProcessor == nullptr || oldProcessor->getConfig().getPresetID() == config.getPresetID()) + return; + + closePluginEditor(); + closeSubgraphEditorsForGraph (oldProcessor->getGraph().get()); + + yup::MemoryBlock oldState; + if (const auto saveResult = oldProcessor->saveStateIntoMemory (oldState); saveResult.failed()) + { + statusLabel.setText ("Subgraph save failed: " + saveResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + auto props = ownerGraph->getNodeProperties (nodeID).value_or (yup::AudioGraphNodeProperties()); + props.identifier = NodeRegistry::subgraphIdentifier; + props.name = config.getDisplayName(); + props.creationData = SubgraphConfig::toCreationData (config); + + auto replacement = std::make_unique (config); + replacement->setNodeFactory (nodeRegistry.makeProcessorFactory()); + + if (const auto loadResult = replacement->loadStateFromMemory (oldState); loadResult.failed()) + { + statusLabel.setText ("Subgraph reload failed: " + loadResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + if (const auto replaceResult = ownerGraph->replaceNodeProcessor (nodeID, std::move (replacement), props); replaceResult.failed()) + { + statusLabel.setText ("Subgraph reconfigure failed: " + replaceResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + if (const auto commitResult = ownerGraph->commitChanges(); commitResult.failed()) + { + statusLabel.setText ("Subgraph commit failed: " + commitResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + refreshNodeViewsForGraph (ownerGraph.get(), nodeID); + + auto* newProcessor = dynamic_cast (ownerGraph->getNodeProcessor (nodeID)); + auto* record = findSubgraphEditorRecord (ownerGraph.get(), nodeID); + + if (newProcessor != nullptr && record != nullptr) + { + record->window->setTitle (config.getDisplayName()); + record->window->setEditorPanel (createSubgraphPanel (newProcessor->getGraph()), config); + } + } + + void refreshNodeViewsForGraph (const yup::AudioGraphProcessor* graphToRefresh, yup::AudioGraphNodeID nodeID) + { + if (graphToRefresh == graph.get()) + { + graphComponent->removeNodeView (nodeID); + loadedNodes.erase (nodeID); + + if (auto props = graph->getNodeProperties (nodeID)) + { + auto* proc = graph->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); + + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), { props->positionX, props->positionY }); + loadedNodes[nodeID] = props->identifier; + } + } + } + + for (auto& record : subgraphEditorWindows) + if (record.ownerGraphRaw == graphToRefresh && record.window != nullptr && record.window->getEditorPanel() != nullptr) + record.window->getEditorPanel()->refreshNodeView (nodeID); + } + + void closeSubgraphEditorForNode (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) + { + if (ownerGraph == nullptr) + return; + + if (auto* subgraph = dynamic_cast (ownerGraph->getNodeProcessor (nodeID))) + closeSubgraphEditorsForGraph (subgraph->getGraph().get()); + + subgraphEditorWindows.erase (std::remove_if (subgraphEditorWindows.begin(), subgraphEditorWindows.end(), [ownerGraphRaw = ownerGraph.get(), nodeID] (SubgraphEditorRecord& record) + { + return record.ownerGraphRaw == ownerGraphRaw && record.nodeID == nodeID; + }), + subgraphEditorWindows.end()); + } + + void closeSubgraphEditorsForGraph (const yup::AudioGraphProcessor* graphToClose) + { + bool removed = true; + + while (removed) + { + removed = false; + + for (auto& record : subgraphEditorWindows) + { + if (record.ownerGraphRaw == graphToClose) + { + closeSubgraphEditorForNode (record.ownerGraph, record.nodeID); + removed = true; + break; + } + } + } + } + + void closeAllSubgraphEditors() + { + subgraphEditorWindows.clear(); + } + + SubgraphEditorRecord* findSubgraphEditorRecord (const yup::AudioGraphProcessor* ownerGraph, yup::AudioGraphNodeID nodeID) + { + const auto iterator = std::find_if (subgraphEditorWindows.begin(), subgraphEditorWindows.end(), [ownerGraph, nodeID] (const SubgraphEditorRecord& record) + { + return record.ownerGraphRaw == ownerGraph && record.nodeID == nodeID; + }); + + return iterator != subgraphEditorWindows.end() ? &*iterator : nullptr; + } + +#if YUP_DESKTOP + void addPluginNodeToPanel (AudioGraphEditorPanel& editor, + const yup::AudioPluginDescription& desc, + yup::Point canvasPos) + { + auto* format = scanner->getFormatForType (desc.formatType); + if (format == nullptr) + 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); + + editor.addProcessorNode (std::move (result).getValue(), std::move (props), canvasPos); + } +#endif + //============================================================================== #if YUP_DESKTOP @@ -707,7 +942,9 @@ class AudioGraphApp final yup::FileChooser::Ptr fileChooser; std::unique_ptr pluginEditorWindow; + const yup::AudioGraphProcessor* activePluginEditorGraph = nullptr; yup::AudioGraphNodeID activePluginEditorNodeID { yup::AudioGraphNodeID::invalid() }; + std::vector subgraphEditorWindows; yup::TextButton newButton; yup::TextButton openButton; diff --git a/examples/audiograph/source/main.cpp b/examples/audiograph/source/main.cpp index 02afc4e9d..42d1dce72 100644 --- a/examples/audiograph/source/main.cpp +++ b/examples/audiograph/source/main.cpp @@ -34,14 +34,6 @@ #include #endif -#include "nodes/OscillatorNode.h" -#include "nodes/GainNode.h" -#include "nodes/LatencyNode.h" -#include "nodes/LowPassFilterNode.h" -#include "nodes/SamplePlayerNode.h" -#include "nodes/PluginNodeView.h" -#include "nodes/NodeRegistry.h" -#include "ui/PluginEditorWindow.h" #include "AudioGraphApp.h" //============================================================================== diff --git a/examples/audiograph/source/nodes/GraphEndpointNodeViews.h b/examples/audiograph/source/nodes/GraphEndpointNodeViews.h new file mode 100644 index 000000000..519096db0 --- /dev/null +++ b/examples/audiograph/source/nodes/GraphEndpointNodeViews.h @@ -0,0 +1,27 @@ +/* + ============================================================================== + + 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 "GraphInputNodeView.h" +#include "GraphOutputNodeView.h" +#include "SoundCardInputNodeView.h" +#include "SoundCardOutputNodeView.h" diff --git a/examples/audiograph/source/nodes/GraphInputNodeView.h b/examples/audiograph/source/nodes/GraphInputNodeView.h new file mode 100644 index 000000000..e46a06bdb --- /dev/null +++ b/examples/audiograph/source/nodes/GraphInputNodeView.h @@ -0,0 +1,73 @@ +/* + ============================================================================== + + 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 + +//============================================================================== +class GraphInputNodeView final : public yup::AudioGraphNodeView +{ +public: + GraphInputNodeView (std::shared_ptr graphIn, yup::StringRef subtitleIn) + : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + , graph (std::move (graphIn)) + , subtitle (subtitleIn) + { + } + + yup::String getNodeTitle() const override { return "INPUT"; } + + yup::String getNodeSubtitle() const override { return subtitle; } + + int getNumInputPorts() const override { return 0; } + + int getNumOutputPorts() const override + { + return graph != nullptr ? static_cast (graph->getBusLayout().getInputBuses().size()) : 0; + } + + int getPreferredWidth() const override { return 160; } + + yup::Color getNodeColor() const override { return yup::Color (0xfff97316); } + + PortInfo getOutputPortInfo (int busIndex) const override + { + if (graph == nullptr) + return { "?", getPortKindColor (PortKind::audio), PortKind::audio }; + + return getPortInfo (graph->getBusLayout().getInputBuses(), busIndex); + } + +private: + static PortInfo getPortInfo (yup::Span buses, int busIndex) + { + if (busIndex < 0 || busIndex >= static_cast (buses.size())) + return { "?", getPortKindColor (PortKind::audio), PortKind::audio }; + + const auto& bus = buses[static_cast (busIndex)]; + const auto kind = bus.getType() == yup::AudioBus::Type::Audio ? PortKind::audio : PortKind::midi; + return { bus.getName(), getPortKindColor (kind), kind }; + } + + std::shared_ptr graph; + yup::String subtitle; +}; diff --git a/examples/audiograph/source/nodes/GraphOutputNodeView.h b/examples/audiograph/source/nodes/GraphOutputNodeView.h new file mode 100644 index 000000000..e5adb5d51 --- /dev/null +++ b/examples/audiograph/source/nodes/GraphOutputNodeView.h @@ -0,0 +1,73 @@ +/* + ============================================================================== + + 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 + +//============================================================================== +class GraphOutputNodeView final : public yup::AudioGraphNodeView +{ +public: + GraphOutputNodeView (std::shared_ptr graphIn, yup::StringRef subtitleIn) + : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + , graph (std::move (graphIn)) + , subtitle (subtitleIn) + { + } + + yup::String getNodeTitle() const override { return "OUTPUT"; } + + yup::String getNodeSubtitle() const override { return subtitle; } + + int getNumInputPorts() const override + { + return graph != nullptr ? static_cast (graph->getBusLayout().getOutputBuses().size()) : 0; + } + + int getNumOutputPorts() const override { return 0; } + + int getPreferredWidth() const override { return 160; } + + yup::Color getNodeColor() const override { return yup::Color (0xff06b6d4); } + + PortInfo getInputPortInfo (int busIndex) const override + { + if (graph == nullptr) + return { "?", getPortKindColor (PortKind::audio), PortKind::audio }; + + return getPortInfo (graph->getBusLayout().getOutputBuses(), busIndex); + } + +private: + static PortInfo getPortInfo (yup::Span buses, int busIndex) + { + if (busIndex < 0 || busIndex >= static_cast (buses.size())) + return { "?", getPortKindColor (PortKind::audio), PortKind::audio }; + + const auto& bus = buses[static_cast (busIndex)]; + const auto kind = bus.getType() == yup::AudioBus::Type::Audio ? PortKind::audio : PortKind::midi; + return { bus.getName(), getPortKindColor (kind), kind }; + } + + std::shared_ptr graph; + yup::String subtitle; +}; diff --git a/examples/audiograph/source/nodes/NodeRegistry.h b/examples/audiograph/source/nodes/NodeRegistry.h index 954ce5c49..f76716d64 100644 --- a/examples/audiograph/source/nodes/NodeRegistry.h +++ b/examples/audiograph/source/nodes/NodeRegistry.h @@ -26,6 +26,14 @@ #include #include +#include "GainNode.h" +#include "LatencyNode.h" +#include "LowPassFilterNode.h" +#include "OscillatorNode.h" +#include "PluginNodeView.h" +#include "SamplePlayerNode.h" +#include "SubgraphNode.h" + //============================================================================== /** Maps stable string identifiers to processor and view factories. @@ -54,6 +62,9 @@ class NodeRegistry /** Stable factory key for the built-in looping sample player node. */ static constexpr const char* samplePlayerIdentifier = "internal.samplePlayer"; + /** Stable factory key for the built-in recursive subgraph node. */ + static constexpr const char* subgraphIdentifier = "internal.subgraph"; + /** Stable factory key for Unknown plugin nodes. */ static constexpr const char* pluginUnknownIdentifier = "plugin.unknown"; @@ -163,6 +174,25 @@ class NodeRegistry return std::make_unique (nodeID, *samplePlayer); } }; + + entries[subgraphIdentifier] = { + [this] (const yup::AudioGraphNodeProperties& props) -> yup::ResultValue> + { + auto processor = std::make_unique (SubgraphConfig::fromCreationData (props.creationData)); + processor->setNodeFactory (makeProcessorFactory()); + + std::unique_ptr result = std::move (processor); + return yup::makeResultValueOk (std::move (result)); + }, + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr + { + auto* subgraph = dynamic_cast (proc); + if (subgraph == nullptr) + return nullptr; + + return std::make_unique (nodeID, *subgraph); + } + }; } #if YUP_DESKTOP @@ -359,13 +389,7 @@ class NodeRegistry //============================================================================== std::vector getInternalNodeIdentifiers() const { - return { - oscillatorIdentifier, - gainIdentifier, - latencyIdentifier, - lpfIdentifier, - samplePlayerIdentifier - }; + return { oscillatorIdentifier, gainIdentifier, lpfIdentifier, samplePlayerIdentifier, subgraphIdentifier }; } //============================================================================== @@ -392,6 +416,8 @@ class NodeRegistry if (id == samplePlayerIdentifier) return "Sample Player"; + if (id == subgraphIdentifier) + return "Subgraph"; return id; } diff --git a/examples/audiograph/source/nodes/SoundCardInputNodeView.h b/examples/audiograph/source/nodes/SoundCardInputNodeView.h new file mode 100644 index 000000000..65c1f2b8a --- /dev/null +++ b/examples/audiograph/source/nodes/SoundCardInputNodeView.h @@ -0,0 +1,53 @@ +/* + ============================================================================== + + 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 SoundCardInputNodeView final : public yup::AudioGraphNodeView +{ +public: + explicit SoundCardInputNodeView (yup::StringRef subtitleIn = "sound card") + : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + , subtitle (subtitleIn) + { + } + + yup::String getNodeTitle() const override { return "INPUT"; } + + yup::String getNodeSubtitle() const override { return subtitle; } + + 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 }; + } + +private: + yup::String subtitle; +}; diff --git a/examples/audiograph/source/nodes/SoundCardOutputNodeView.h b/examples/audiograph/source/nodes/SoundCardOutputNodeView.h new file mode 100644 index 000000000..9263cb89f --- /dev/null +++ b/examples/audiograph/source/nodes/SoundCardOutputNodeView.h @@ -0,0 +1,53 @@ +/* + ============================================================================== + + 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 SoundCardOutputNodeView final : public yup::AudioGraphNodeView +{ +public: + explicit SoundCardOutputNodeView (yup::StringRef subtitleIn = "sound card") + : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + , subtitle (subtitleIn) + { + } + + yup::String getNodeTitle() const override { return "OUTPUT"; } + + yup::String getNodeSubtitle() const override { return subtitle; } + + 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 }; + } + +private: + yup::String subtitle; +}; diff --git a/examples/audiograph/source/nodes/SubgraphNode.h b/examples/audiograph/source/nodes/SubgraphNode.h new file mode 100644 index 000000000..f0bf645f4 --- /dev/null +++ b/examples/audiograph/source/nodes/SubgraphNode.h @@ -0,0 +1,373 @@ +/* + ============================================================================== + + 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" + +//============================================================================== +struct SubgraphConfig +{ + enum Preset + { + mono = 1, + stereo, + monoMidi, + stereoMidi + }; + + int audioChannels = 2; + bool midiEnabled = false; + + static SubgraphConfig fromPreset (int presetID) + { + switch (presetID) + { + case mono: + return { 1, false }; + case stereo: + return { 2, false }; + case monoMidi: + return { 1, true }; + case stereoMidi: + return { 2, true }; + default: + return {}; + } + } + + int getPresetID() const noexcept + { + if (audioChannels == 1) + return midiEnabled ? monoMidi : mono; + + return midiEnabled ? stereoMidi : stereo; + } + + yup::String getDisplayName() const + { + return audioChannels == 1 ? "Mono Subgraph" : "Stereo Subgraph"; + } + + yup::String getSubtitle() const + { + auto text = audioChannels == 1 ? yup::String ("mono") : yup::String ("stereo"); + + if (midiEnabled) + text += " + MIDI"; + + return text; + } + + static yup::MemoryBlock toCreationData (const SubgraphConfig& config) + { + yup::XmlElement xml ("SubgraphConfig"); + xml.setAttribute ("audioChannels", yup::jlimit (1, 2, config.audioChannels)); + xml.setAttribute ("midiEnabled", config.midiEnabled ? 1 : 0); + + yup::MemoryBlock block; + yup::MemoryOutputStream stream (block, false); + xml.writeTo (stream); + stream.flush(); + return block; + } + + static SubgraphConfig fromCreationData (const yup::MemoryBlock& block) + { + if (block.getSize() == 0) + return {}; + + yup::MemoryInputStream stream (block, false); + auto xml = yup::parseXML (stream.readEntireStreamAsString()); + + if (xml == nullptr || ! xml->hasTagName ("SubgraphConfig")) + return {}; + + SubgraphConfig config; + config.audioChannels = yup::jlimit (1, 2, xml->getIntAttribute ("audioChannels", 2)); + config.midiEnabled = xml->getBoolAttribute ("midiEnabled", false); + return config; + } +}; + +//============================================================================== +class SubgraphProcessor final : public yup::AudioProcessor +{ +public: + explicit SubgraphProcessor (SubgraphConfig configIn = {}) + : AudioProcessor ("Subgraph", createBusLayout (configIn)) + , config (configIn) + , graph (std::make_shared (createBusLayout (configIn))) + { + } + + const SubgraphConfig& getConfig() const noexcept { return config; } + + std::shared_ptr getGraph() const noexcept { return graph; } + + void setNodeFactory (yup::AudioGraphProcessor::NodeFactory factory) + { + graph->setNodeFactory (std::move (factory)); + } + + void prepareToPlay (float sampleRate, int maxBlockSize) override + { + graph->setPlayHead (getPlayHead()); + graph->prepareToPlay (sampleRate, maxBlockSize); + } + + void releaseResources() override + { + graph->releaseResources(); + } + + void processBlock (yup::AudioBuffer& audioBuffer, yup::MidiBuffer& midiBuffer) override + { + graph->processBlock (audioBuffer, midiBuffer); + } + + void flush() override + { + graph->flush(); + } + + int getLatencySamples() override + { + return graph->getLatencySamples(); + } + + 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.getSize() == 0) + return yup::Result::ok(); + + yup::MemoryInputStream stream (data, false); + auto xml = yup::parseXML (stream.readEntireStreamAsString()); + + if (xml == nullptr || ! xml->hasTagName ("SubgraphState")) + return yup::Result::fail ("Invalid subgraph state"); + + if (xml->getIntAttribute ("version", 0) != 1) + return yup::Result::fail ("Unsupported subgraph state version"); + + auto* graphState = xml->getChildByName ("graphState"); + if (graphState == nullptr || graphState->getStringAttribute ("encoding") != "base64") + return yup::Result::fail ("Subgraph state is missing graph data"); + + yup::MemoryBlock graphBlock; + yup::MemoryOutputStream output (graphBlock, false); + const auto base64Text = graphState->getAllSubText().removeCharacters (" \t\r\n"); + + if (! yup::Base64::convertFromBase64 (output, base64Text)) + return yup::Result::fail ("Subgraph state has invalid graph data"); + + output.flush(); + + SubgraphConfig savedConfig; + savedConfig.audioChannels = yup::jlimit (1, 2, xml->getIntAttribute ("audioChannels", config.audioChannels)); + savedConfig.midiEnabled = xml->getBoolAttribute ("midiEnabled", config.midiEnabled); + + return loadPrunedGraphState (graphBlock, savedConfig); + } + + yup::Result saveStateIntoMemory (yup::MemoryBlock& data) override + { + yup::MemoryBlock graphBlock; + const auto result = graph->saveStateIntoMemory (graphBlock); + + if (result.failed()) + return result; + + yup::XmlElement xml ("SubgraphState"); + xml.setAttribute ("version", 1); + xml.setAttribute ("audioChannels", yup::jlimit (1, 2, config.audioChannels)); + xml.setAttribute ("midiEnabled", config.midiEnabled ? 1 : 0); + + auto* graphState = new yup::XmlElement ("graphState"); + graphState->setAttribute ("encoding", "base64"); + graphState->addTextElement (yup::Base64::toBase64 (graphBlock.getData(), graphBlock.getSize())); + xml.addChildElement (graphState); + + yup::MemoryOutputStream stream (data, false); + xml.writeTo (stream); + stream.flush(); + return yup::Result::ok(); + } + + bool hasEditor() const override { return false; } + + yup::AudioProcessorEditor* createEditor() override { return nullptr; } + + static yup::AudioBusLayout createBusLayout (const SubgraphConfig& config) + { + std::vector inputs; + std::vector outputs; + + const auto channels = yup::jlimit (1, 2, config.audioChannels); + inputs.emplace_back ("Audio In", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Input, channels); + outputs.emplace_back ("Audio Out", yup::AudioBus::Type::Audio, yup::AudioBus::Direction::Output, channels); + + if (config.midiEnabled) + { + inputs.emplace_back ("MIDI In", yup::AudioBus::Type::MIDI, yup::AudioBus::Direction::Input, 0); + outputs.emplace_back ("MIDI Out", yup::AudioBus::Type::MIDI, yup::AudioBus::Direction::Output, 0); + } + + return yup::AudioBusLayout (std::move (inputs), std::move (outputs)); + } + +private: + yup::Result loadPrunedGraphState (const yup::MemoryBlock& graphBlock, const SubgraphConfig& savedConfig) + { + yup::MemoryInputStream stream (graphBlock, false); + auto xml = yup::parseXML (stream.readEntireStreamAsString()); + + if (xml == nullptr) + return yup::Result::fail ("Invalid nested graph state"); + + pruneInvalidGraphEndpointConnections (*xml, savedConfig); + + yup::MemoryBlock prunedBlock; + yup::MemoryOutputStream output (prunedBlock, false); + xml->writeTo (output); + output.flush(); + + return graph->loadStateFromMemory (prunedBlock); + } + + void pruneInvalidGraphEndpointConnections (yup::XmlElement& graphXml, const SubgraphConfig& savedConfig) const + { + auto* connections = graphXml.getChildByName ("connections"); + if (connections == nullptr) + return; + + std::vector toRemove; + + for (auto* connection : connections->getChildWithTagNameIterator ("connection")) + { + auto* source = connection->getChildByName ("source"); + auto* destination = connection->getChildByName ("destination"); + + if ((source != nullptr && ! graphEndpointIsValid (*source, savedConfig)) + || (destination != nullptr && ! graphEndpointIsValid (*destination, savedConfig))) + toRemove.push_back (connection); + } + + for (auto* connection : toRemove) + connections->removeChildElement (connection, true); + } + + bool graphEndpointIsValid (const yup::XmlElement& endpoint, const SubgraphConfig& savedConfig) const + { + const auto kind = endpoint.getStringAttribute ("kind"); + + if (kind != "graphInput" && kind != "graphOutput") + return true; + + const int busIndex = endpoint.getIntAttribute ("busIndex", -1); + const auto previousLayout = createBusLayout (savedConfig); + const auto previousBuses = kind == "graphInput" ? previousLayout.getInputBuses() + : previousLayout.getOutputBuses(); + const auto currentBuses = kind == "graphInput" ? getBusLayout().getInputBuses() + : getBusLayout().getOutputBuses(); + + if (busIndex < 0 + || busIndex >= static_cast (previousBuses.size()) + || busIndex >= static_cast (currentBuses.size())) + return false; + + const auto& previousBus = previousBuses[static_cast (busIndex)]; + const auto& currentBus = currentBuses[static_cast (busIndex)]; + + return previousBus.getType() == currentBus.getType() + && previousBus.getNumChannels() == currentBus.getNumChannels(); + } + + SubgraphConfig config; + std::shared_ptr graph; +}; + +//============================================================================== +class SubgraphNodeView final : public yup::AudioGraphNodeView +{ +public: + SubgraphNodeView (yup::AudioGraphNodeID nodeID, SubgraphProcessor& processorIn) + : AudioGraphNodeView (nodeID) + , processor (processorIn) + { + } + + yup::String getNodeTitle() const override { return "SUBGRAPH"; } + + yup::String getNodeSubtitle() const override { return processor.getConfig().getSubtitle(); } + + int getNumInputPorts() const override + { + return static_cast (processor.getBusLayout().getInputBuses().size()); + } + + int getNumOutputPorts() const override + { + return static_cast (processor.getBusLayout().getOutputBuses().size()); + } + + int getPreferredWidth() const override { return 230; } + + yup::Color getNodeColor() const override { return yup::Color (0xff8b5cf6); } + + PortInfo getInputPortInfo (int busIndex) const override + { + return getPortInfo (processor.getBusLayout().getInputBuses(), busIndex); + } + + PortInfo getOutputPortInfo (int busIndex) const override + { + return getPortInfo (processor.getBusLayout().getOutputBuses(), busIndex); + } + +private: + static PortInfo getPortInfo (yup::Span buses, int busIndex) + { + if (busIndex < 0 || busIndex >= static_cast (buses.size())) + return { "?", getPortKindColor (PortKind::audio), PortKind::audio }; + + const auto& bus = buses[static_cast (busIndex)]; + const auto kind = bus.getType() == yup::AudioBus::Type::Audio ? PortKind::audio + : PortKind::midi; + + return { bus.getName(), getPortKindColor (kind), kind }; + } + + SubgraphProcessor& processor; +}; diff --git a/examples/audiograph/source/ui/AudioGraphEditorPanel.h b/examples/audiograph/source/ui/AudioGraphEditorPanel.h new file mode 100644 index 000000000..09c30d781 --- /dev/null +++ b/examples/audiograph/source/ui/AudioGraphEditorPanel.h @@ -0,0 +1,272 @@ +/* + ============================================================================== + + 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 "../nodes/GraphEndpointNodeViews.h" +#include "../nodes/NodeRegistry.h" +#include "../nodes/SubgraphNode.h" + +//============================================================================== +class AudioGraphEditorPanel final + : public yup::Component + , public yup::AudioGraphComponent::Listener +{ +public: + AudioGraphEditorPanel (std::shared_ptr graphIn, + NodeRegistry& nodeRegistryIn, + yup::StringRef endpointSubtitleIn) + : graph (std::move (graphIn)) + , nodeRegistry (nodeRegistryIn) + , endpointSubtitle (endpointSubtitleIn) + { + 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) + { + if (onNodeDoubleClicked != nullptr) + onNodeDoubleClicked (*this, id); + }; + graphComponent->addListener (this); + addAndMakeVisible (*graphComponent); + + reloadViews(); + } + + ~AudioGraphEditorPanel() override + { + graphComponent->removeListener (this); + } + + void resized() override + { + graphComponent->setBounds (getLocalBounds()); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (yup::Color (0xff0d1117)); + g.fillAll(); + } + + void nodeViewMoved (yup::AudioGraphNodeID nodeID, yup::Point newCanvasPos) override + { + graph->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + } + + std::shared_ptr getGraph() const noexcept { return graph; } + + void reloadViews() + { + for (auto& [nodeID, _] : loadedNodes) + graphComponent->removeNodeView (nodeID); + + loadedNodes.clear(); + + for (auto nodeID : graph->getNodeIDs()) + addViewForNode (nodeID); + + graphComponent->setGraphInputView (std::make_unique (graph, endpointSubtitle), { 40.0f, 200.0f }); + graphComponent->setGraphOutputView (std::make_unique (graph, endpointSubtitle), { 760.0f, 200.0f }); + graphComponent->zoomToFitNodes(); + } + + void refreshNodeView (yup::AudioGraphNodeID nodeID) + { + graphComponent->removeNodeView (nodeID); + loadedNodes.erase (nodeID); + addViewForNode (nodeID); + } + + void addProcessorNode (std::unique_ptr processor, + yup::AudioGraphNodeProperties props, + yup::Point canvasPos) + { + const auto identifier = props.identifier; + const auto nodeID = graph->addNode (std::move (processor), std::move (props)); + if (! nodeID.isValid()) + return; + + graph->commitChanges(); + + auto* rawProc = graph->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, identifier, rawProc, graph.get()); + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), canvasPos); + loadedNodes[nodeID] = identifier; + } + } + + void addInternalNode (const yup::String& identifier, + yup::Point canvasPos, + yup::MemoryBlock creationData = {}) + { + yup::AudioGraphNodeProperties props; + props.identifier = identifier; + props.name = identifier == NodeRegistry::subgraphIdentifier + ? SubgraphConfig::fromCreationData (creationData).getDisplayName() + : NodeRegistry::identifierToDisplayName (identifier); + props.positionX = canvasPos.getX(); + props.positionY = canvasPos.getY(); + props.creationData = std::move (creationData); + + auto processorResult = nodeRegistry.makeProcessorFactory() (props); + if (processorResult.failed()) + return; + + addProcessorNode (std::move (processorResult).getValue(), std::move (props), canvasPos); + } + + std::function onNodeDoubleClicked; + std::function onNodeWillBeRemoved; + +#if YUP_DESKTOP + std::function)> onPluginSelected; + std::function&()> getDiscoveredPlugins; +#endif + +private: + void addViewForNode (yup::AudioGraphNodeID nodeID) + { + auto props = graph->getNodeProperties (nodeID); + if (! props.has_value()) + return; + + auto* proc = graph->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); + + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), { props->positionX, props->positionY }); + loadedNodes[nodeID] = props->identifier; + } + } + + void removeNode (yup::AudioGraphNodeID nodeID) + { + if (onNodeWillBeRemoved != nullptr) + onNodeWillBeRemoved (*this, nodeID); + + graphComponent->removeNodeView (nodeID); + graph->removeNode (nodeID); + graph->commitChanges(); + loadedNodes.erase (nodeID); + } + + 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(); + + auto internalNodes = nodeRegistry.getInternalNodeIdentifiers(); + for (const auto& identifier : internalNodes) + { + const auto displayName = NodeRegistry::identifierToDisplayName (identifier); + internalSubMenu->addItem (displayName, static_cast (internalSubMenu->getNumItems() + 1)); + } + + auto subgraphSubMenu = yup::PopupMenu::create(); + subgraphSubMenu->addItem ("Mono Subgraph", 10); + subgraphSubMenu->addItem ("Stereo Subgraph", 11); + subgraphSubMenu->addItem ("Mono Subgraph + MIDI", 12); + subgraphSubMenu->addItem ("Stereo Subgraph + MIDI", 13); + + activeMenu = yup::PopupMenu::create (options); + activeMenu->addSubMenu ("Internal Nodes", internalSubMenu); + activeMenu->addSubMenu ("Subgraphs", subgraphSubMenu); + +#if YUP_DESKTOP + if (getDiscoveredPlugins != nullptr) + { + const auto& plugins = getDiscoveredPlugins(); + + if (! plugins.empty()) + { + auto pluginSubMenu = yup::PopupMenu::create(); + + for (int i = 0; i < static_cast (plugins.size()); ++i) + pluginSubMenu->addItem (plugins[static_cast (i)].name, 100 + i); + + activeMenu->addSubMenu ("Plugins", pluginSubMenu); + } + } +#endif + + activeMenu->show ([this, internalNodes = std::move (internalNodes)] (int selectedID) + { + if (selectedID <= 0) + return; + + if (selectedID >= 1 && selectedID <= static_cast (internalNodes.size())) + { + addInternalNode (internalNodes[selectedID - 1], pendingMenuCanvasPos); + } + else if (selectedID >= 10 && selectedID <= 13) + { + const auto preset = selectedID == 10 ? SubgraphConfig::mono + : selectedID == 11 ? SubgraphConfig::stereo + : selectedID == 12 ? SubgraphConfig::monoMidi + : SubgraphConfig::stereoMidi; + const auto config = SubgraphConfig::fromPreset (preset); + addInternalNode (NodeRegistry::subgraphIdentifier, + pendingMenuCanvasPos, + SubgraphConfig::toCreationData (config)); + } +#if YUP_DESKTOP + else if (selectedID >= 100 && onPluginSelected != nullptr && getDiscoveredPlugins != nullptr) + { + const auto& plugins = getDiscoveredPlugins(); + const auto index = static_cast (selectedID - 100); + + if (index < plugins.size()) + onPluginSelected (*this, plugins[index], pendingMenuCanvasPos); + } +#endif + }); + } + + std::shared_ptr graph; + NodeRegistry& nodeRegistry; + yup::String endpointSubtitle; + std::unique_ptr graphComponent; + std::map loadedNodes; + yup::PopupMenu::Ptr activeMenu; + yup::Point pendingMenuCanvasPos; +}; diff --git a/examples/audiograph/source/ui/SubgraphEditorWindow.h b/examples/audiograph/source/ui/SubgraphEditorWindow.h new file mode 100644 index 000000000..435a99306 --- /dev/null +++ b/examples/audiograph/source/ui/SubgraphEditorWindow.h @@ -0,0 +1,108 @@ +/* + ============================================================================== + + 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 "AudioGraphEditorPanel.h" + +//============================================================================== +class SubgraphEditorWindow final : public yup::DocumentWindow +{ +public: + SubgraphEditorWindow (yup::StringRef title, + std::unique_ptr panelToOwn, + const SubgraphConfig& config, + std::function onPresetChangedIn, + std::function onClosedIn) + : yup::DocumentWindow (yup::ComponentNative::Options().withAllowedHighDensityDisplay (true).withResizableWindow (true), + yup::Color (0xff0d1117)) + , panel (std::move (panelToOwn)) + , onPresetChanged (std::move (onPresetChangedIn)) + , onClosed (std::move (onClosedIn)) + { + setTitle (title); + configureCombo (config); + addAndMakeVisible (ioLabel); + addAndMakeVisible (ioCombo); + addAndMakeVisible (*panel); + } + + void setEditorPanel (std::unique_ptr panelToOwn, const SubgraphConfig& config) + { + removeChildComponent (panel.get()); + panel = std::move (panelToOwn); + addAndMakeVisible (*panel); + configureCombo (config); + resized(); + } + + AudioGraphEditorPanel* getEditorPanel() const noexcept { return panel.get(); } + + void resized() override + { + auto bounds = getLocalBounds(); + auto toolbar = bounds.removeFromTop (36); + + ioLabel.setBounds (toolbar.removeFromLeft (64).reduced (4, 4)); + ioCombo.setBounds (toolbar.removeFromLeft (190).reduced (4, 4)); + panel->setBounds (bounds); + } + + void keyDown (const yup::KeyPress& keys, const yup::Point&) override + { + if (keys.getKey() == yup::KeyPress::escapeKey) + userTriedToCloseWindow(); + } + + void userTriedToCloseWindow() override + { + if (onClosed != nullptr) + onClosed(); + } + +private: + void configureCombo (const SubgraphConfig& config) + { + ioLabel.setText ("I/O", yup::dontSendNotification); + + ioCombo.onSelectedItemChanged = nullptr; + ioCombo.clear(); + ioCombo.addItem ("Mono", SubgraphConfig::mono); + ioCombo.addItem ("Stereo", SubgraphConfig::stereo); + ioCombo.addItem ("Mono + MIDI", SubgraphConfig::monoMidi); + ioCombo.addItem ("Stereo + MIDI", SubgraphConfig::stereoMidi); + ioCombo.setSelectedId (config.getPresetID(), yup::dontSendNotification); + ioCombo.onSelectedItemChanged = [this] + { + if (onPresetChanged != nullptr) + onPresetChanged (ioCombo.getSelectedId()); + }; + } + + std::unique_ptr panel; + yup::Label ioLabel; + yup::ComboBox ioCombo; + std::function onPresetChanged; + std::function onClosed; +}; diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp index 92ffd7835..ea7890aff 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp @@ -205,6 +205,49 @@ class AudioGraphProcessor::Pimpl MemoryBlock state; }; + struct EndpointDescriptor + { + bool valid = false; + GraphSignalType type = GraphSignalType::audio; + int channels = 0; + }; + + static EndpointDescriptor describeNodeEndpoint (const AudioProcessor& processor, const AudioGraphEndpoint& endpoint) + { + const auto buses = endpoint.getKind() == AudioGraphEndpoint::Kind::nodeInput + ? processor.getBusLayout().getInputBuses() + : processor.getBusLayout().getOutputBuses(); + + if (! isValidBusIndex (buses, endpoint.getBusIndex())) + return {}; + + const auto& bus = buses[static_cast (endpoint.getBusIndex())]; + return { true, + toSignalType (bus.getType()), + bus.getType() == AudioBus::Type::Audio ? bus.getNumChannels() : 0 }; + } + + static bool connectionEndpointIsStillCompatible (const AudioGraphEndpoint& endpoint, + AudioGraphNodeID replacedNodeID, + const AudioProcessor* oldProcessor, + const AudioProcessor* newProcessor) + { + if (endpoint.getNodeID() != replacedNodeID) + return true; + + if (endpoint.getKind() != AudioGraphEndpoint::Kind::nodeInput + && endpoint.getKind() != AudioGraphEndpoint::Kind::nodeOutput) + return true; + + const auto oldDescriptor = describeNodeEndpoint (*oldProcessor, endpoint); + const auto newDescriptor = describeNodeEndpoint (*newProcessor, endpoint); + + return oldDescriptor.valid + && newDescriptor.valid + && oldDescriptor.type == newDescriptor.type + && oldDescriptor.channels == newDescriptor.channels; + } + class WorkerThread final : public Thread { public: @@ -272,6 +315,52 @@ class AudioGraphProcessor::Pimpl return true; } + Result replaceNodeProcessor (AudioGraphNodeID nodeID, + std::unique_ptr processor, + AudioGraphNodeProperties properties) + { + if (! nodeID.isValid()) + return Result::fail ("Audio graph node ID is invalid"); + + if (processor == nullptr) + return Result::fail ("Audio graph replacement processor is empty"); + + 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 Result::fail ("Audio graph node does not exist"); + + auto replacement = std::shared_ptr (std::move (processor)); + + if (properties.name.isEmpty()) + properties.name = replacement->getName(); + + auto& node = *nodeIterator; + auto oldProcessor = node.processor; + + modelConnections.erase (std::remove_if (modelConnections.begin(), modelConnections.end(), [nodeID, &oldProcessor, &replacement] (const AudioGraphConnection& connection) + { + return ! connectionEndpointIsStillCompatible (connection.source, nodeID, oldProcessor.get(), replacement.get()) + || ! connectionEndpointIsStillCompatible (connection.destination, nodeID, oldProcessor.get(), replacement.get()); + }), + modelConnections.end()); + + node.processor = std::move (replacement); + node.properties = std::move (properties); + node.preparedSampleRate = 0.0f; + node.preparedBlockSize = 0; + node.prepared = false; + + ++modelRevision; + dirty.store (true); + return Result::ok(); + } + Result addConnection (const AudioGraphConnection& connection) { if (! connection.source.isSource()) @@ -1675,6 +1764,13 @@ bool AudioGraphProcessor::removeNode (AudioGraphNodeID nodeID) return pimpl->removeNode (nodeID); } +Result AudioGraphProcessor::replaceNodeProcessor (AudioGraphNodeID nodeID, + std::unique_ptr processor, + AudioGraphNodeProperties properties) +{ + return pimpl->replaceNodeProcessor (nodeID, std::move (processor), std::move (properties)); +} + Result AudioGraphProcessor::addConnection (const AudioGraphConnection& connection) { return pimpl->addConnection (connection); diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h index 831c2bb68..7a0fcd2e0 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h @@ -59,6 +59,18 @@ class YUP_API AudioGraphProcessor final : public AudioProcessor /** Removes a processor node and all connections that mention it. */ bool removeNode (AudioGraphNodeID nodeID); + /** + Replaces a processor node while preserving its node identifier. + + Connections that reference an input or output bus whose type or channel + count no longer matches the replacement processor are removed from the + control-thread graph model. The caller must still call commitChanges() + to publish the new processing plan. + */ + Result replaceNodeProcessor (AudioGraphNodeID nodeID, + std::unique_ptr processor, + AudioGraphNodeProperties properties); + /** Adds a connection to the control-thread graph model. */ Result addConnection (const AudioGraphConnection& connection); diff --git a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp index f73116cc3..0a7ddf750 100644 --- a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp +++ b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp @@ -2663,3 +2663,69 @@ TEST (AudioGraphProcessorTests, GetNodeIDsReturnsAllAddedNodes) EXPECT_EQ (1u, idsAfter.size()); EXPECT_EQ (idsAfter.end(), std::find (idsAfter.begin(), idsAfter.end(), idA)); } + +TEST (AudioGraphProcessorTests, ReplaceNodeProcessorPreservesNodeIDAndCompatibleConnections) +{ + 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()); + + AudioGraphNodeProperties props; + props.identifier = "replacement"; + props.name = "Replacement"; + + EXPECT_TRUE (graph.replaceNodeProcessor (node, std::make_unique (0.25f), props).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + const auto ids = graph.getNodeIDs(); + EXPECT_EQ (1u, ids.size()); + EXPECT_EQ (node, ids.front()); + EXPECT_EQ (2u, graph.getConnections().size()); + + 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, ReplaceNodeProcessorPrunesIncompatibleConnections) +{ + 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()); + + AudioGraphNodeProperties props; + props.identifier = "mono"; + props.name = "Mono"; + + EXPECT_TRUE (graph.replaceNodeProcessor (node, std::make_unique(), props).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + + const auto ids = graph.getNodeIDs(); + EXPECT_EQ (1u, ids.size()); + EXPECT_EQ (node, ids.front()); + EXPECT_TRUE (graph.getConnections().empty()); +} + +TEST (AudioGraphProcessorTests, ReplaceNodeProcessorRejectsInvalidRequests) +{ + AudioGraphProcessor graph; + const auto node = graph.addNode (std::make_unique()); + + AudioGraphNodeProperties props; + props.identifier = "replacement"; + + EXPECT_TRUE (graph.replaceNodeProcessor (AudioGraphNodeID::invalid(), std::make_unique(), props).failed()); + EXPECT_TRUE (graph.replaceNodeProcessor (AudioGraphNodeID (999), std::make_unique(), props).failed()); + EXPECT_TRUE (graph.replaceNodeProcessor (node, nullptr, props).failed()); +} From fbb900f06e590ea7d7fd243d873544f42b84a01b Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 20 May 2026 22:12:44 +0200 Subject: [PATCH 04/14] Split the audio graph to a model --- examples/audiograph/source/AudioGraphApp.h | 148 ++- .../audiograph/source/nodes/LatencyNode.h | 44 +- .../audiograph/source/nodes/NodeRegistry.h | 22 +- .../audiograph/source/nodes/NodeViewHelpers.h | 2 +- .../audiograph/source/nodes/SubgraphNode.h | 10 +- .../source/ui/AudioGraphEditorPanel.h | 114 +- .../graph/yup_AudioGraphModel.cpp | 771 +++++++++++++ .../graph/yup_AudioGraphModel.h | 197 ++++ .../graph/yup_AudioGraphProcessor.cpp | 933 +++------------ .../graph/yup_AudioGraphProcessor.h | 80 +- modules/yup_audio_graph/yup_audio_graph.cpp | 1 + modules/yup_audio_graph/yup_audio_graph.h | 4 +- .../graph/yup_AudioGraphComponent.cpp | 108 +- .../graph/yup_AudioGraphComponent.h | 97 +- .../graph/yup_AudioGraphNodeView.h | 44 +- .../native/yup_AudioPluginInstance_AUv2.mm | 49 + .../native/yup_AudioPluginInstance_CLAP.cpp | 25 + .../native/yup_AudioPluginInstance_VST3.cpp | 40 +- .../processors/yup_AudioProcessor.cpp | 26 + .../processors/yup_AudioProcessor.h | 41 +- .../yup_audio_processors.h | 2 + .../themes/theme_v1/yup_ThemeVersion1.cpp | 10 +- tests/yup_audio_graph.cpp | 1 + .../yup_AudioGraphProcessor.cpp | 1016 ++++++++++------- .../yup_audio_gui/yup_AudioGraphComponent.cpp | 50 +- 25 files changed, 2343 insertions(+), 1492 deletions(-) create mode 100644 modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp create mode 100644 modules/yup_audio_graph/graph/yup_AudioGraphModel.h diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h index d4c5c4494..0166bd789 100644 --- a/examples/audiograph/source/AudioGraphApp.h +++ b/examples/audiograph/source/AudioGraphApp.h @@ -46,7 +46,8 @@ class AudioGraphApp final { deviceManager.initialiseWithDefaultDevices (2, 2); - graph = std::make_shared(); + model = std::make_shared(); + graph = std::make_shared (model); nodeRegistry.registerInternalNodes(); #if YUP_DESKTOP @@ -65,21 +66,30 @@ class AudioGraphApp final nodeRegistry.registerPluginFormats (scanner.get(), makeHostContext()); #endif - graph->setNodeFactory (nodeRegistry.makeProcessorFactory()); + model->setNodeFactory (nodeRegistry.makeProcessorFactory()); graphComponent = std::make_unique (graph); - graphComponent->onCanvasContextMenu = [this] (yup::Point canvasPos) + + graphComponent->onConnectionRequested = [this] (const yup::AudioGraphConnection& connection) + { + return addConnection (connection); + }; + + graphComponent->onConnectionRemovalRequested = [this] (const yup::AudioGraphConnection& connection) { - showAddNodeMenu (canvasPos); + return removeConnection (connection); }; - graphComponent->onNodeContextMenu = [this] (yup::AudioGraphNodeID id, yup::Point) + + graphComponent->onEndpointConnectionsRemovalRequested = [this] (const yup::AudioGraphEndpoint& endpoint) { - removeNode (id); + return removeConnectionsForEndpoint (endpoint); }; - graphComponent->onNodeDoubleClicked = [this] (yup::AudioGraphNodeID id) + + graphComponent->onNodeMoveRequested = [this] (yup::AudioGraphNodeID nodeID, yup::Point oldCanvasPos, yup::Point newCanvasPos) { - openNodeEditor (graph, id); + return moveNode (nodeID, oldCanvasPos, newCanvasPos); }; + graphComponent->addListener (this); addAndMakeVisible (*graphComponent); @@ -199,10 +209,24 @@ class AudioGraphApp final //============================================================================== // AudioGraphComponent::Listener - void nodeViewMoved (yup::AudioGraphNodeID nodeID, yup::Point newCanvasPos) override { - graph->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + } + + void nodeContextMenu (yup::AudioGraphNodeID nodeID, yup::Point) override + { + removeNode (nodeID); + } + + void nodeDoubleClicked (yup::AudioGraphNodeID nodeID) override + { + openNodeEditor (graph, nodeID); + } + + void canvasContextMenu (yup::Point canvasPos) override + { + showAddNodeMenu (canvasPos); } private: @@ -254,7 +278,7 @@ class AudioGraphApp final graphComponent->removeNodeView (nodeID); loadedNodes.clear(); - graph->clear(); + model->clear(); graph->commitChanges(); currentFilePath = yup::File(); @@ -348,7 +372,7 @@ class AudioGraphApp final graphComponent->removeNodeView (nodeID); loadedNodes.clear(); - graph->clear(); + model->clear(); const auto result = graph->loadStateFromMemory (mb); if (result.failed()) @@ -361,13 +385,13 @@ class AudioGraphApp final graph->commitChanges(); currentFilePath = file; - for (auto nodeID : graph->getNodeIDs()) + for (auto nodeID : model->getNodeIDs()) { - auto props = graph->getNodeProperties (nodeID); + auto props = model->getNodeProperties (nodeID); if (! props.has_value()) continue; - auto* proc = graph->getNodeProcessor (nodeID); + auto* proc = model->getNodeProcessor (nodeID); auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); if (view != nullptr) @@ -490,7 +514,7 @@ class AudioGraphApp final return; } - const auto nodeID = graph->addNode (std::move (processorResult).getValue(), props); + const auto nodeID = model->addNode (std::move (processorResult).getValue(), props); if (! nodeID.isValid()) { statusLabel.setText ("Failed to add node to graph.", yup::dontSendNotification); @@ -499,7 +523,7 @@ class AudioGraphApp final graph->commitChanges(); - auto* rawProc = graph->getNodeProcessor (nodeID); + auto* rawProc = model->getNodeProcessor (nodeID); auto view = nodeRegistry.createView (nodeID, identifier, rawProc, graph.get()); if (view != nullptr) { @@ -532,7 +556,7 @@ class AudioGraphApp final props.positionY = canvasPos.getY(); props.creationData = NodeRegistry::descriptionToCreationData (desc); - const auto nodeID = graph->addNode (std::move (result).getValue(), props); + const auto nodeID = model->addNode (std::move (result).getValue(), props); if (! nodeID.isValid()) { statusLabel.setText ("Failed to add plugin to graph.", yup::dontSendNotification); @@ -541,7 +565,7 @@ class AudioGraphApp final graph->commitChanges(); - auto* rawProc = graph->getNodeProcessor (nodeID); + auto* rawProc = model->getNodeProcessor (nodeID); auto view = nodeRegistry.createView (nodeID, props.identifier, rawProc, graph.get()); if (view != nullptr) { @@ -561,11 +585,69 @@ class AudioGraphApp final closeSubgraphEditorForNode (graph, nodeID); graphComponent->removeNodeView (nodeID); - graph->removeNode (nodeID); + model->removeNode (nodeID); graph->commitChanges(); loadedNodes.erase (nodeID); } + bool addConnection (const yup::AudioGraphConnection& connection) + { + if (model->addConnection (connection).failed()) + return false; + + if (graph->commitChanges().wasOk()) + return true; + + model->removeConnection (connection); + graph->commitChanges(); + return false; + } + + bool removeConnection (const yup::AudioGraphConnection& connection) + { + if (! model->removeConnection (connection)) + return false; + + if (graph->commitChanges().wasOk()) + return true; + + model->addConnection (connection); + graph->commitChanges(); + return false; + } + + bool removeConnectionsForEndpoint (const yup::AudioGraphEndpoint& endpoint) + { + const auto connections = model->getConnections(); + std::vector removedConnections; + + for (const auto& connection : connections) + { + if (connection.source == endpoint || connection.destination == endpoint) + { + if (model->removeConnection (connection)) + removedConnections.push_back (connection); + } + } + + if (removedConnections.empty()) + return false; + + if (graph->commitChanges().wasOk()) + return true; + + for (const auto& connection : removedConnections) + model->addConnection (connection); + + graph->commitChanges(); + return false; + } + + bool moveNode (yup::AudioGraphNodeID nodeID, yup::Point, yup::Point newCanvasPos) + { + return model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + } + //============================================================================== void openNodeEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) @@ -573,7 +655,8 @@ class AudioGraphApp final if (ownerGraph == nullptr) return; - auto* proc = ownerGraph->getNodeProcessor (nodeID); + auto ownerModel = ownerGraph->getModel(); + auto* proc = ownerModel->getNodeProcessor (nodeID); if (auto* subgraph = dynamic_cast (proc)) { openSubgraphEditor (ownerGraph, nodeID, *subgraph); @@ -591,7 +674,8 @@ class AudioGraphApp final return; } - auto* proc = ownerGraph->getNodeProcessor (nodeID); + auto ownerModel = ownerGraph->getModel(); + auto* proc = ownerModel->getNodeProcessor (nodeID); if (proc == nullptr || ! proc->hasEditor()) return; @@ -712,7 +796,8 @@ class AudioGraphApp final if (ownerGraph == nullptr) return; - auto* oldProcessor = dynamic_cast (ownerGraph->getNodeProcessor (nodeID)); + auto ownerModel = ownerGraph->getModel(); + auto* oldProcessor = dynamic_cast (ownerModel->getNodeProcessor (nodeID)); if (oldProcessor == nullptr || oldProcessor->getConfig().getPresetID() == config.getPresetID()) return; @@ -726,13 +811,14 @@ class AudioGraphApp final return; } - auto props = ownerGraph->getNodeProperties (nodeID).value_or (yup::AudioGraphNodeProperties()); + auto props = ownerModel->getNodeProperties (nodeID).value_or (yup::AudioGraphNodeProperties()); props.identifier = NodeRegistry::subgraphIdentifier; props.name = config.getDisplayName(); props.creationData = SubgraphConfig::toCreationData (config); auto replacement = std::make_unique (config); - replacement->setNodeFactory (nodeRegistry.makeProcessorFactory()); + auto replacementModel = replacement->getModel(); + replacementModel->setNodeFactory (nodeRegistry.makeProcessorFactory()); if (const auto loadResult = replacement->loadStateFromMemory (oldState); loadResult.failed()) { @@ -740,7 +826,7 @@ class AudioGraphApp final return; } - if (const auto replaceResult = ownerGraph->replaceNodeProcessor (nodeID, std::move (replacement), props); replaceResult.failed()) + if (const auto replaceResult = ownerModel->replaceNode (nodeID, std::move (replacement), props); replaceResult.failed()) { statusLabel.setText ("Subgraph reconfigure failed: " + replaceResult.getErrorMessage(), yup::dontSendNotification); return; @@ -754,7 +840,7 @@ class AudioGraphApp final refreshNodeViewsForGraph (ownerGraph.get(), nodeID); - auto* newProcessor = dynamic_cast (ownerGraph->getNodeProcessor (nodeID)); + auto* newProcessor = dynamic_cast (ownerModel->getNodeProcessor (nodeID)); auto* record = findSubgraphEditorRecord (ownerGraph.get(), nodeID); if (newProcessor != nullptr && record != nullptr) @@ -771,9 +857,9 @@ class AudioGraphApp final graphComponent->removeNodeView (nodeID); loadedNodes.erase (nodeID); - if (auto props = graph->getNodeProperties (nodeID)) + if (auto props = model->getNodeProperties (nodeID)) { - auto* proc = graph->getNodeProcessor (nodeID); + auto* proc = model->getNodeProcessor (nodeID); auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); if (view != nullptr) @@ -794,7 +880,8 @@ class AudioGraphApp final if (ownerGraph == nullptr) return; - if (auto* subgraph = dynamic_cast (ownerGraph->getNodeProcessor (nodeID))) + auto ownerModel = ownerGraph->getModel(); + if (auto* subgraph = dynamic_cast (ownerModel->getNodeProcessor (nodeID))) closeSubgraphEditorsForGraph (subgraph->getGraph().get()); subgraphEditorWindows.erase (std::remove_if (subgraphEditorWindows.begin(), subgraphEditorWindows.end(), [ownerGraphRaw = ownerGraph.get(), nodeID] (SubgraphEditorRecord& record) @@ -934,6 +1021,7 @@ class AudioGraphApp final yup::AudioDeviceManager deviceManager; std::shared_ptr graph; + std::shared_ptr model; std::unique_ptr graphComponent; NodeRegistry nodeRegistry; std::map loadedNodes; diff --git a/examples/audiograph/source/nodes/LatencyNode.h b/examples/audiograph/source/nodes/LatencyNode.h index ebbc80fdd..1b25d20b8 100644 --- a/examples/audiograph/source/nodes/LatencyNode.h +++ b/examples/audiograph/source/nodes/LatencyNode.h @@ -35,6 +35,7 @@ class LatencyProcessor final : public yup::AudioProcessor { yup::AudioBus ("Main", yup::AudioBus::Audio, yup::AudioBus::Output, 2) })) { setDelayMilliseconds (defaultDelayMilliseconds); + reportUpdatedLatency(); } void prepareToPlay (float newSampleRate, int maxBlockSize) override @@ -46,7 +47,6 @@ class LatencyProcessor final : public yup::AudioProcessor history.clear(); writePosition = 0; - updateDelaySamples(); } void releaseResources() override {} @@ -59,8 +59,7 @@ class LatencyProcessor final : public yup::AudioProcessor void processBlock (yup::AudioBuffer& audioBuffer, yup::MidiBuffer&) override { - const int currentDelaySamples = delaySamples.load (std::memory_order_relaxed); - + const int currentDelaySamples = getLatencySamples(); if (currentDelaySamples <= 0) return; @@ -88,11 +87,6 @@ class LatencyProcessor final : public yup::AudioProcessor } } - int getLatencySamples() override - { - return delaySamples.load (std::memory_order_relaxed); - } - int getCurrentPreset() const noexcept override { return 0; } void setCurrentPreset (int) noexcept override {} @@ -115,6 +109,8 @@ class LatencyProcessor final : public yup::AudioProcessor return yup::Result::fail ("Unsupported latency node state version"); setDelayMilliseconds (stream.readFloat()); + reportUpdatedLatency(); + return yup::Result::ok(); } @@ -142,10 +138,17 @@ class LatencyProcessor final : public yup::AudioProcessor return delaySamples.load (std::memory_order_relaxed); } - void setDelayMilliseconds (float newDelayMilliseconds) noexcept + void setDelayMilliseconds (float newDelayMilliseconds) { delayMilliseconds.store (yup::jlimit (0.0f, maximumDelayMilliseconds, newDelayMilliseconds), std::memory_order_relaxed); - updateDelaySamples(); + + const auto newDelaySamples = delayMillisecondsToSamples (delayMilliseconds.load (std::memory_order_relaxed)); + delaySamples.store (newDelaySamples, std::memory_order_relaxed); + } + + void reportUpdatedLatency() + { + setLatencySamples (getDelaySamples()); } private: @@ -158,11 +161,6 @@ class LatencyProcessor final : public yup::AudioProcessor return yup::roundToInt ((static_cast (milliseconds) * static_cast (sr)) / 1000.0); } - void updateDelaySamples() noexcept - { - delaySamples.store (delayMillisecondsToSamples (delayMilliseconds.load (std::memory_order_relaxed)), std::memory_order_relaxed); - } - std::atomic sampleRate { 44100.0f }; std::atomic delayMilliseconds { defaultDelayMilliseconds }; std::atomic delaySamples { 0 }; @@ -174,10 +172,9 @@ class LatencyProcessor final : public yup::AudioProcessor class LatencyNodeView final : public yup::AudioGraphNodeView { public: - LatencyNodeView (yup::AudioGraphNodeID nodeID, LatencyProcessor& processorIn, yup::AudioGraphProcessor* graphIn) + LatencyNodeView (yup::AudioGraphNodeID nodeID, LatencyProcessor& processorIn) : AudioGraphNodeView (nodeID) , processor (processorIn) - , graph (graphIn) , delaySlider (yup::Slider::LinearBarHorizontal) { NodeViewHelpers::configureParameterSlider (delaySlider, getPortKindColor (PortKind::parameter)); @@ -188,15 +185,11 @@ class LatencyNodeView final : public yup::AudioGraphNodeView { processor.setDelayMilliseconds (static_cast (value)); - if (! delaySlider.isCurrentlyBeingDragged()) - commitLatencyChange(); - repaint(); }; delaySlider.onDragEnd = [this] (const yup::MouseEvent&) { - commitLatencyChange(); - repaint(); + processor.reportUpdatedLatency(); }; addAndMakeVisible (delaySlider); } @@ -233,13 +226,6 @@ class LatencyNodeView final : public yup::AudioGraphNodeView } private: - void commitLatencyChange() - { - if (graph != nullptr) - graph->commitChanges(); - } - LatencyProcessor& processor; - yup::AudioGraphProcessor* graph = nullptr; yup::Slider delaySlider; }; diff --git a/examples/audiograph/source/nodes/NodeRegistry.h b/examples/audiograph/source/nodes/NodeRegistry.h index f76716d64..ac03d1507 100644 --- a/examples/audiograph/source/nodes/NodeRegistry.h +++ b/examples/audiograph/source/nodes/NodeRegistry.h @@ -38,7 +38,7 @@ /** Maps stable string identifiers to processor and view factories. - NodeRegistry drives both the AudioGraphProcessor::NodeFactory (used for + NodeRegistry drives both the AudioGraphModel::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(). @@ -135,13 +135,13 @@ class NodeRegistry { return yup::makeResultValueOk (std::make_unique()); }, - [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor* graph) -> std::unique_ptr + [] (yup::AudioGraphNodeID nodeID, yup::AudioProcessor* proc, yup::AudioGraphProcessor*) -> std::unique_ptr { auto* latency = dynamic_cast (proc); if (latency == nullptr) return nullptr; - return std::make_unique (nodeID, *latency, graph); + return std::make_unique (nodeID, *latency); } }; @@ -344,13 +344,13 @@ class NodeRegistry //============================================================================== /** - Returns an AudioGraphProcessor::NodeFactory that dispatches to the + Returns an AudioGraphModel::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() + yup::AudioGraphModel::NodeFactory makeProcessorFactory() { return [this] (const yup::AudioGraphNodeProperties& props) -> yup::ResultValue> @@ -370,8 +370,7 @@ class NodeRegistry @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. - @param graph The graph that owns the node, used by views that need to - commit metadata-affecting parameter changes. + @param graph The graph that owns the node, used by graph-aware views. @returns A new view, or nullptr if the identifier is unknown. */ std::unique_ptr createView (yup::AudioGraphNodeID nodeID, @@ -389,7 +388,14 @@ class NodeRegistry //============================================================================== std::vector getInternalNodeIdentifiers() const { - return { oscillatorIdentifier, gainIdentifier, lpfIdentifier, samplePlayerIdentifier, subgraphIdentifier }; + return { + oscillatorIdentifier, + gainIdentifier, + lpfIdentifier, + latencyIdentifier, + samplePlayerIdentifier, + subgraphIdentifier + }; } //============================================================================== diff --git a/examples/audiograph/source/nodes/NodeViewHelpers.h b/examples/audiograph/source/nodes/NodeViewHelpers.h index 1bf571659..2159b5362 100644 --- a/examples/audiograph/source/nodes/NodeViewHelpers.h +++ b/examples/audiograph/source/nodes/NodeViewHelpers.h @@ -29,7 +29,7 @@ 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::trackColorId, yup::Color (0xff26282c).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)); diff --git a/examples/audiograph/source/nodes/SubgraphNode.h b/examples/audiograph/source/nodes/SubgraphNode.h index f0bf645f4..1b60a7c0b 100644 --- a/examples/audiograph/source/nodes/SubgraphNode.h +++ b/examples/audiograph/source/nodes/SubgraphNode.h @@ -118,7 +118,8 @@ class SubgraphProcessor final : public yup::AudioProcessor explicit SubgraphProcessor (SubgraphConfig configIn = {}) : AudioProcessor ("Subgraph", createBusLayout (configIn)) , config (configIn) - , graph (std::make_shared (createBusLayout (configIn))) + , model (std::make_shared()) + , graph (std::make_shared (model, createBusLayout (configIn))) { } @@ -126,9 +127,11 @@ class SubgraphProcessor final : public yup::AudioProcessor std::shared_ptr getGraph() const noexcept { return graph; } - void setNodeFactory (yup::AudioGraphProcessor::NodeFactory factory) + std::shared_ptr getModel() const noexcept { return model; } + + void setNodeFactory (yup::AudioGraphModel::NodeFactory factory) { - graph->setNodeFactory (std::move (factory)); + model->setNodeFactory (std::move (factory)); } void prepareToPlay (float sampleRate, int maxBlockSize) override @@ -315,6 +318,7 @@ class SubgraphProcessor final : public yup::AudioProcessor } SubgraphConfig config; + std::shared_ptr model; std::shared_ptr graph; }; diff --git a/examples/audiograph/source/ui/AudioGraphEditorPanel.h b/examples/audiograph/source/ui/AudioGraphEditorPanel.h index 09c30d781..d575a1c3b 100644 --- a/examples/audiograph/source/ui/AudioGraphEditorPanel.h +++ b/examples/audiograph/source/ui/AudioGraphEditorPanel.h @@ -40,23 +40,32 @@ class AudioGraphEditorPanel final NodeRegistry& nodeRegistryIn, yup::StringRef endpointSubtitleIn) : graph (std::move (graphIn)) + , model (graph != nullptr ? graph->getModel() : nullptr) , nodeRegistry (nodeRegistryIn) , endpointSubtitle (endpointSubtitleIn) { graphComponent = std::make_unique (graph); - graphComponent->onCanvasContextMenu = [this] (yup::Point canvasPos) + + graphComponent->onConnectionRequested = [this] (const yup::AudioGraphConnection& connection) + { + return addConnection (connection); + }; + + graphComponent->onConnectionRemovalRequested = [this] (const yup::AudioGraphConnection& connection) { - showAddNodeMenu (canvasPos); + return removeConnection (connection); }; - graphComponent->onNodeContextMenu = [this] (yup::AudioGraphNodeID id, yup::Point) + + graphComponent->onEndpointConnectionsRemovalRequested = [this] (const yup::AudioGraphEndpoint& endpoint) { - removeNode (id); + return removeConnectionsForEndpoint (endpoint); }; - graphComponent->onNodeDoubleClicked = [this] (yup::AudioGraphNodeID id) + + graphComponent->onNodeMoveRequested = [this] (yup::AudioGraphNodeID nodeID, yup::Point oldCanvasPos, yup::Point newCanvasPos) { - if (onNodeDoubleClicked != nullptr) - onNodeDoubleClicked (*this, id); + return moveNode (nodeID, oldCanvasPos, newCanvasPos); }; + graphComponent->addListener (this); addAndMakeVisible (*graphComponent); @@ -68,6 +77,8 @@ class AudioGraphEditorPanel final graphComponent->removeListener (this); } + std::shared_ptr getGraph() const noexcept { return graph; } + void resized() override { graphComponent->setBounds (getLocalBounds()); @@ -81,10 +92,24 @@ class AudioGraphEditorPanel final void nodeViewMoved (yup::AudioGraphNodeID nodeID, yup::Point newCanvasPos) override { - graph->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); } - std::shared_ptr getGraph() const noexcept { return graph; } + void nodeContextMenu (yup::AudioGraphNodeID id, yup::Point) override + { + removeNode (id); + } + + void nodeDoubleClicked (yup::AudioGraphNodeID id) override + { + if (onNodeDoubleClicked != nullptr) + onNodeDoubleClicked (*this, id); + } + + void canvasContextMenu (yup::Point canvasPos) override + { + showAddNodeMenu (canvasPos); + } void reloadViews() { @@ -93,7 +118,7 @@ class AudioGraphEditorPanel final loadedNodes.clear(); - for (auto nodeID : graph->getNodeIDs()) + for (auto nodeID : model->getNodeIDs()) addViewForNode (nodeID); graphComponent->setGraphInputView (std::make_unique (graph, endpointSubtitle), { 40.0f, 200.0f }); @@ -113,13 +138,13 @@ class AudioGraphEditorPanel final yup::Point canvasPos) { const auto identifier = props.identifier; - const auto nodeID = graph->addNode (std::move (processor), std::move (props)); + const auto nodeID = model->addNode (std::move (processor), std::move (props)); if (! nodeID.isValid()) return; graph->commitChanges(); - auto* rawProc = graph->getNodeProcessor (nodeID); + auto* rawProc = model->getNodeProcessor (nodeID); auto view = nodeRegistry.createView (nodeID, identifier, rawProc, graph.get()); if (view != nullptr) { @@ -159,11 +184,11 @@ class AudioGraphEditorPanel final private: void addViewForNode (yup::AudioGraphNodeID nodeID) { - auto props = graph->getNodeProperties (nodeID); + auto props = model->getNodeProperties (nodeID); if (! props.has_value()) return; - auto* proc = graph->getNodeProcessor (nodeID); + auto* proc = model->getNodeProcessor (nodeID); auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); if (view != nullptr) @@ -179,11 +204,69 @@ class AudioGraphEditorPanel final onNodeWillBeRemoved (*this, nodeID); graphComponent->removeNodeView (nodeID); - graph->removeNode (nodeID); + model->removeNode (nodeID); graph->commitChanges(); loadedNodes.erase (nodeID); } + bool addConnection (const yup::AudioGraphConnection& connection) + { + if (model->addConnection (connection).failed()) + return false; + + if (graph->commitChanges().wasOk()) + return true; + + model->removeConnection (connection); + graph->commitChanges(); + return false; + } + + bool removeConnection (const yup::AudioGraphConnection& connection) + { + if (! model->removeConnection (connection)) + return false; + + if (graph->commitChanges().wasOk()) + return true; + + model->addConnection (connection); + graph->commitChanges(); + return false; + } + + bool removeConnectionsForEndpoint (const yup::AudioGraphEndpoint& endpoint) + { + const auto connections = model->getConnections(); + std::vector removedConnections; + + for (const auto& connection : connections) + { + if (connection.source == endpoint || connection.destination == endpoint) + { + if (model->removeConnection (connection)) + removedConnections.push_back (connection); + } + } + + if (removedConnections.empty()) + return false; + + if (graph->commitChanges().wasOk()) + return true; + + for (const auto& connection : removedConnections) + model->addConnection (connection); + + graph->commitChanges(); + return false; + } + + bool moveNode (yup::AudioGraphNodeID nodeID, yup::Point, yup::Point newCanvasPos) + { + return model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + } + void showAddNodeMenu (yup::Point canvasPos) { pendingMenuCanvasPos = canvasPos; @@ -263,6 +346,7 @@ class AudioGraphEditorPanel final } std::shared_ptr graph; + std::shared_ptr model; NodeRegistry& nodeRegistry; yup::String endpointSubtitle; std::unique_ptr graphComponent; diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp new file mode 100644 index 000000000..4669039fa --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp @@ -0,0 +1,771 @@ +/* + ============================================================================== + + 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 int audioGraphModelStateVersion = 1; +constexpr const char* audioGraphModelStateTag = "YUPAudioGraphState"; + +enum class ModelSignalType +{ + audio, + midi +}; + +bool modelIsValidBusIndex (Span buses, int busIndex) +{ + return busIndex >= 0 && busIndex < static_cast (buses.size()); +} + +ModelSignalType modelToSignalType (AudioBus::Type busType) +{ + return busType == AudioBus::Type::Audio ? ModelSignalType::audio : ModelSignalType::midi; +} + +struct ModelEndpointDescriptor +{ + bool valid = false; + ModelSignalType type = ModelSignalType::audio; + int channels = 0; +}; + +ModelEndpointDescriptor modelDescribeNodeEndpoint (const AudioProcessor& processor, const AudioGraphEndpoint& endpoint) +{ + const auto buses = endpoint.getKind() == AudioGraphEndpoint::Kind::nodeInput + ? processor.getBusLayout().getInputBuses() + : processor.getBusLayout().getOutputBuses(); + + if (! modelIsValidBusIndex (buses, endpoint.getBusIndex())) + return {}; + + const auto& bus = buses[static_cast (endpoint.getBusIndex())]; + return { true, + modelToSignalType (bus.getType()), + bus.getType() == AudioBus::Type::Audio ? bus.getNumChannels() : 0 }; +} + +bool modelConnectionEndpointIsStillCompatible (const AudioGraphEndpoint& endpoint, + AudioGraphNodeID replacedNodeID, + const AudioProcessor* oldProcessor, + const AudioProcessor* newProcessor) +{ + if (endpoint.getNodeID() != replacedNodeID) + return true; + + if (endpoint.getKind() != AudioGraphEndpoint::Kind::nodeInput + && endpoint.getKind() != AudioGraphEndpoint::Kind::nodeOutput) + return true; + + const auto oldDescriptor = modelDescribeNodeEndpoint (*oldProcessor, endpoint); + const auto newDescriptor = modelDescribeNodeEndpoint (*newProcessor, endpoint); + + return oldDescriptor.valid + && newDescriptor.valid + && oldDescriptor.type == newDescriptor.type + && oldDescriptor.channels == newDescriptor.channels; +} + +AudioGraphNodeProperties modelMakeDefaultNodeProperties (const AudioProcessor& processor) +{ + AudioGraphNodeProperties properties; + properties.identifier = processor.getName(); + properties.name = processor.getName(); + return properties; +} + +void modelWriteBase64Element (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); +} + +Result modelReadBase64Element (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(); +} + +void modelWriteNodeProperties (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)); + modelWriteBase64Element (element, "creationData", properties.creationData); +} + +Result modelReadNodeProperties (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 modelReadBase64Element (element, "creationData", properties.creationData); +} + +String modelEndpointKindToString (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 {}; +} + +Result modelEndpointKindFromString (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"); +} + +void modelWriteEndpoint (XmlElement& element, const AudioGraphEndpoint& endpoint) +{ + element.setAttribute ("kind", modelEndpointKindToString (endpoint.getKind())); + element.setAttribute ("nodeID", String (static_cast (endpoint.getNodeID().getRawID()))); + element.setAttribute ("busIndex", endpoint.getBusIndex()); +} + +Result modelReadEndpoint (const XmlElement& element, AudioGraphEndpoint& endpoint) +{ + auto kind = AudioGraphEndpoint::Kind::graphInput; + const auto result = modelEndpointKindFromString (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"); +} + +DataTree modelCreateEndpointTree (const Identifier& type, const AudioGraphEndpoint& endpoint) +{ + return DataTree (type, + { { "kind", modelEndpointKindToString (endpoint.getKind()) }, + { "nodeID", static_cast (endpoint.getNodeID().getRawID()) }, + { "busIndex", endpoint.getBusIndex() } }); +} +} // namespace + +//============================================================================== +AudioGraphModel::AudioGraphModel() +{ + rebuildDataTree(); +} + +AudioGraphModel::~AudioGraphModel() = default; + +AudioGraphNodeID AudioGraphModel::addNode (std::unique_ptr processor) +{ + if (processor == nullptr) + return AudioGraphNodeID::invalid(); + + auto properties = modelMakeDefaultNodeProperties (*processor); + return addNode (std::move (processor), std::move (properties)); +} + +AudioGraphNodeID AudioGraphModel::addNode (std::unique_ptr processor, + AudioGraphNodeProperties properties) +{ + if (processor == nullptr) + return AudioGraphNodeID::invalid(); + + const std::lock_guard lock (mutex); + + const auto id = AudioGraphNodeID (++nextNodeID); + if (properties.name.isEmpty()) + properties.name = processor->getName(); + + nodes.push_back ({ id, std::shared_ptr (std::move (processor)), std::move (properties) }); + markTopologyChanged(); + return id; +} + +bool AudioGraphModel::removeNode (AudioGraphNodeID nodeID) +{ + if (! nodeID.isValid()) + return false; + + const std::lock_guard lock (mutex); + + const auto nodeIterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (nodeIterator == nodes.end()) + return false; + + nodes.erase (nodeIterator); + + connections.erase (std::remove_if (connections.begin(), connections.end(), [nodeID] (const AudioGraphConnection& connection) + { + return connection.source.getNodeID() == nodeID || connection.destination.getNodeID() == nodeID; + }), + connections.end()); + + markTopologyChanged(); + return true; +} + +Result AudioGraphModel::replaceNode (AudioGraphNodeID nodeID, + std::unique_ptr processor, + AudioGraphNodeProperties properties) +{ + if (! nodeID.isValid()) + return Result::fail ("Audio graph node ID is invalid"); + + if (processor == nullptr) + return Result::fail ("Audio graph replacement processor is empty"); + + const std::lock_guard lock (mutex); + + const auto nodeIterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (nodeIterator == nodes.end()) + return Result::fail ("Audio graph node does not exist"); + + auto replacement = std::shared_ptr (std::move (processor)); + + if (properties.name.isEmpty()) + properties.name = replacement->getName(); + + auto& node = *nodeIterator; + auto oldProcessor = node.processor; + + connections.erase (std::remove_if (connections.begin(), connections.end(), [nodeID, &oldProcessor, &replacement] (const AudioGraphConnection& connection) + { + return ! modelConnectionEndpointIsStillCompatible (connection.source, nodeID, oldProcessor.get(), replacement.get()) + || ! modelConnectionEndpointIsStillCompatible (connection.destination, nodeID, oldProcessor.get(), replacement.get()); + }), + connections.end()); + + node.processor = std::move (replacement); + node.properties = std::move (properties); + + markTopologyChanged(); + return Result::ok(); +} + +Result AudioGraphModel::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 (mutex); + + if (std::find (connections.begin(), connections.end(), connection) != connections.end()) + return Result::fail ("Audio graph connection already exists"); + + connections.push_back (connection); + markTopologyChanged(); + return Result::ok(); +} + +bool AudioGraphModel::removeConnection (const AudioGraphConnection& connection) +{ + const std::lock_guard lock (mutex); + const auto oldSize = connections.size(); + + connections.erase (std::remove (connections.begin(), connections.end(), connection), connections.end()); + + const bool removed = connections.size() != oldSize; + if (removed) + markTopologyChanged(); + + return removed; +} + +std::vector AudioGraphModel::getConnections() const +{ + const std::lock_guard lock (mutex); + return connections; +} + +void AudioGraphModel::clear() +{ + const std::lock_guard lock (mutex); + + nodes.clear(); + connections.clear(); + markTopologyChanged(); +} + +AudioProcessor* AudioGraphModel::getNodeProcessor (AudioGraphNodeID nodeID) const noexcept +{ + const std::lock_guard lock (mutex); + + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + return iterator != nodes.end() ? iterator->processor.get() : nullptr; +} + +bool AudioGraphModel::setNodePosition (AudioGraphNodeID nodeID, float positionX, float positionY) +{ + const std::lock_guard lock (mutex); + + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (iterator == nodes.end()) + return false; + + if (iterator->properties.positionX == positionX && iterator->properties.positionY == positionY) + return true; + + iterator->properties.positionX = positionX; + iterator->properties.positionY = positionY; + markMetadataChanged(); + return true; +} + +bool AudioGraphModel::setNodeProperties (AudioGraphNodeID nodeID, AudioGraphNodeProperties properties) +{ + const std::lock_guard lock (mutex); + + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (iterator == nodes.end()) + return false; + + if (properties.name.isEmpty() && iterator->processor != nullptr) + properties.name = iterator->processor->getName(); + + iterator->properties = std::move (properties); + markMetadataChanged(); + return true; +} + +std::optional AudioGraphModel::getNodeProperties (AudioGraphNodeID nodeID) const +{ + const std::lock_guard lock (mutex); + + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (iterator == nodes.end()) + return std::nullopt; + + return iterator->properties; +} + +std::vector AudioGraphModel::getNodeIDs() const +{ + const std::lock_guard lock (mutex); + + std::vector result; + result.reserve (nodes.size()); + + for (const auto& node : nodes) + result.push_back (node.id); + + return result; +} + +void AudioGraphModel::setNodeFactory (AudioGraphModel::NodeFactory factory) +{ + const std::lock_guard lock (factoryMutex); + nodeFactory = std::move (factory); +} + +ResultValue> AudioGraphModel::createXml() const +{ + const auto snapshot = createSnapshot(); + + std::vector savedNodes; + savedNodes.reserve (snapshot.nodes.size()); + + for (const auto& node : snapshot.nodes) + { + 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 (audioGraphModelStateTag); + root->setAttribute ("version", audioGraphModelStateVersion); + root->setAttribute ("nextNodeID", String (static_cast (snapshot.nextNodeID))); + + 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()))); + modelWriteNodeProperties (*nodeElement, node.properties); + modelWriteBase64Element (*nodeElement, "state", node.state); + } + + auto* connectionsElement = new XmlElement ("connections"); + root->addChildElement (connectionsElement); + + for (const auto& connection : snapshot.connections) + { + auto* connectionElement = new XmlElement ("connection"); + connectionsElement->addChildElement (connectionElement); + + auto* sourceElement = new XmlElement ("source"); + connectionElement->addChildElement (sourceElement); + modelWriteEndpoint (*sourceElement, connection.source); + + auto* destinationElement = new XmlElement ("destination"); + connectionElement->addChildElement (destinationElement); + modelWriteEndpoint (*destinationElement, connection.destination); + } + + return makeResultValueOk (std::move (root)); +} + +Result AudioGraphModel::restoreFromXml (const XmlElement& xml) +{ + if (! xml.hasTagName (audioGraphModelStateTag)) + return Result::fail ("Audio graph state has an invalid header"); + + if (xml.getIntAttribute ("version", 0) != audioGraphModelStateVersion) + 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 = modelReadNodeProperties (*nodeElement, savedNode.properties); result.failed()) + return result; + + if (const auto result = modelReadBase64Element (*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 = modelReadEndpoint (*sourceElement, source); result.failed()) + return result; + + if (const auto result = modelReadEndpoint (*destinationElement, destination); result.failed()) + return result; + + savedConnections.push_back ({ source, destination }); + } + + return restoreModel (savedNextNodeID, std::move (savedNodes), std::move (savedConnections)); +} + +AudioGraphModel::Snapshot AudioGraphModel::createSnapshot() const +{ + const std::lock_guard lock (mutex); + + AudioGraphModel::Snapshot snapshot; + snapshot.nodes.reserve (nodes.size()); + + for (const auto& node : nodes) + snapshot.nodes.push_back ({ node.id, node.processor, node.properties }); + + snapshot.connections = connections; + snapshot.nextNodeID = nextNodeID; + snapshot.revision = revision; + snapshot.topologyRevision = topologyRevision; + return snapshot; +} + +void AudioGraphModel::restoreSnapshot (AudioGraphModel::Snapshot snapshot) +{ + const std::lock_guard lock (mutex); + + nodes.clear(); + nodes.reserve (snapshot.nodes.size()); + + for (auto& node : snapshot.nodes) + nodes.push_back ({ node.id, std::move (node.processor), std::move (node.properties) }); + + connections = std::move (snapshot.connections); + nextNodeID = snapshot.nextNodeID; + revision = snapshot.revision; + topologyRevision = snapshot.topologyRevision; + rebuildDataTree(); +} + +uint64_t AudioGraphModel::getRevision() const noexcept +{ + const std::lock_guard lock (mutex); + return revision; +} + +uint64_t AudioGraphModel::getTopologyRevision() const noexcept +{ + const std::lock_guard lock (mutex); + return topologyRevision; +} + +Result AudioGraphModel::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) }); + } + + uint64_t highestSavedNodeID = 0; + for (const auto& savedNode : savedNodes) + highestSavedNodeID = jmax (highestSavedNodeID, savedNode.id.getRawID()); + + { + const std::lock_guard lock (mutex); + nodes = std::move (loadedNodes); + connections = std::move (savedConnections); + nextNodeID = jmax (savedNextNodeID, highestSavedNodeID); + markTopologyChanged(); + } + + return Result::ok(); +} + +ResultValue> AudioGraphModel::createProcessorForSavedNode (const AudioGraphNodeProperties& properties) +{ + AudioGraphModel::NodeFactory factoryCopy; + + { + const std::lock_guard lock (factoryMutex); + factoryCopy = nodeFactory; + } + + if (! factoryCopy) + return makeResultValueFail ("Audio graph node factory is not configured"); + + return factoryCopy (properties); +} + +void AudioGraphModel::markTopologyChanged() +{ + ++revision; + ++topologyRevision; + rebuildDataTree(); +} + +void AudioGraphModel::markMetadataChanged() +{ + ++revision; + rebuildDataTree(); +} + +void AudioGraphModel::rebuildDataTree() +{ + std::vector nodeTrees; + nodeTrees.reserve (nodes.size()); + + for (const auto& node : nodes) + { + nodeTrees.push_back (DataTree ("node", + { { "id", static_cast (node.id.getRawID()) }, + { "identifier", node.properties.identifier }, + { "name", node.properties.name }, + { "positionX", static_cast (node.properties.positionX) }, + { "positionY", static_cast (node.properties.positionY) } })); + } + + std::vector connectionTrees; + connectionTrees.reserve (connections.size()); + + for (const auto& connection : connections) + { + connectionTrees.push_back (DataTree ("connection", + {}, + { modelCreateEndpointTree ("source", connection.source), + modelCreateEndpointTree ("destination", connection.destination) })); + } + + auto nodesTree = DataTree ("nodes"); + { + auto transaction = nodesTree.beginTransaction(); + for (const auto& node : nodeTrees) + transaction.addChild (node); + } + + auto connectionsTree = DataTree ("connections"); + { + auto transaction = connectionsTree.beginTransaction(); + for (const auto& connection : connectionTrees) + transaction.addChild (connection); + } + + data = DataTree ("audioGraphModel", + { { "revision", static_cast (revision) }, + { "topologyRevision", static_cast (topologyRevision) }, + { "nextNodeID", static_cast (nextNodeID) } }, + { nodesTree, connectionsTree }); +} + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.h b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h new file mode 100644 index 000000000..0ae31ce78 --- /dev/null +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h @@ -0,0 +1,197 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** + Editable control-thread topology for an AudioGraphProcessor. + + AudioGraphModel owns the graph nodes, persistent node metadata, connections, + node factory, and XML state serialization. AudioGraphProcessor consumes + immutable model snapshots when compiling a realtime processing plan. + + The model does not own an UndoManager. User and editor code should perform + undoable commands above this layer and call AudioGraphProcessor::commitChanges() + after accepted topology edits. Metadata edits such as node positions and + properties are serialized with the model but do not invalidate the processor's + compiled processing plan. + + @see AudioGraphProcessor, AudioGraphComponent +*/ +class YUP_API AudioGraphModel final +{ +public: + /** Creates a processor for a saved node. */ + using NodeFactory = std::function> (const AudioGraphNodeProperties&)>; + + /** Immutable node entry returned by createSnapshot(). */ + struct NodeSnapshot + { + /** Stable node identifier. */ + AudioGraphNodeID id; + + /** Processor owned by the model and shared with compiled graph plans. */ + std::shared_ptr processor; + + /** Persistent node metadata. */ + AudioGraphNodeProperties properties; + }; + + /** Immutable model state for compilation or rollback. */ + struct Snapshot + { + /** Nodes in model order. */ + std::vector nodes; + + /** Connections in model order. */ + std::vector connections; + + /** Next raw node ID seed. */ + uint64_t nextNodeID = 0; + + /** Model revision at the time the snapshot was taken. */ + uint64_t revision = 0; + + /** Topology revision at the time the snapshot was taken. */ + uint64_t topologyRevision = 0; + }; + + //============================================================================== + /** Constructs an empty graph model. */ + AudioGraphModel(); + + /** Destructs the graph model. */ + ~AudioGraphModel(); + + //============================================================================== + /** 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); + + /** + Replaces a processor node while preserving its node identifier. + + Connections that reference an input or output bus whose type or channel + count no longer matches the replacement processor are removed from the + control-thread graph model. + */ + Result replaceNode (AudioGraphNodeID nodeID, + std::unique_ptr processor, + AudioGraphNodeProperties properties); + + /** Adds a connection to the model. */ + Result addConnection (const AudioGraphConnection& connection); + + /** Removes a connection from the model. */ + bool removeConnection (const AudioGraphConnection& connection); + + /** Returns a snapshot of the current model connections. */ + std::vector getConnections() const; + + /** Removes all graph nodes and connections. */ + void clear(); + + //============================================================================== + /** 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 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. */ + ResultValue> createXml() const; + + /** Restores the model from XML previously created by createXml(). */ + Result restoreFromXml (const XmlElement& xml); + + //============================================================================== + /** Returns an immutable snapshot of the current graph model. */ + Snapshot createSnapshot() const; + + /** Restores the model to a previously captured snapshot. */ + void restoreSnapshot (Snapshot snapshot); + + /** Returns the current model revision. */ + uint64_t getRevision() const noexcept; + + /** Returns the current topology revision. */ + uint64_t getTopologyRevision() const noexcept; + +private: + struct ModelNode + { + AudioGraphNodeID id; + std::shared_ptr processor; + AudioGraphNodeProperties properties; + }; + + struct SavedNodeState + { + AudioGraphNodeID id; + AudioGraphNodeProperties properties; + MemoryBlock state; + }; + + Result restoreModel (uint64_t savedNextNodeID, + std::vector savedNodes, + std::vector savedConnections); + + ResultValue> createProcessorForSavedNode (const AudioGraphNodeProperties& properties); + + void markTopologyChanged(); + void markMetadataChanged(); + void rebuildDataTree(); + + mutable std::mutex mutex; + mutable std::mutex factoryMutex; + std::vector nodes; + std::vector connections; + DataTree data; + uint64_t nextNodeID = 0; + uint64_t revision = 0; + uint64_t topologyRevision = 0; + NodeFactory nodeFactory; + + YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioGraphModel) +}; + +} // namespace yup diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp index ea7890aff..e70bc20c0 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp @@ -79,16 +79,19 @@ constexpr const char* audioGraphStateTag = "YUPAudioGraphState"; } // namespace //============================================================================== -class AudioGraphProcessor::Pimpl +class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener { public: - explicit Pimpl (AudioGraphProcessor& ownerIn) + Pimpl (AudioGraphProcessor& ownerIn, std::shared_ptr modelIn) : owner (ownerIn) + , model (std::move (modelIn)) { + jassert (model != nullptr); } ~Pimpl() { + removeNodeListeners(); resizeWorkers (0); delete pendingPlan.exchange (nullptr); delete currentPlan; @@ -100,11 +103,32 @@ class AudioGraphProcessor::Pimpl AudioGraphNodeID id; std::shared_ptr processor; AudioGraphNodeProperties properties; - float preparedSampleRate = 0.0f; - int preparedBlockSize = 0; + }; + + struct PreparedNodeState + { + std::weak_ptr processor; + float sampleRate = 0.0f; + int blockSize = 0; bool prepared = false; }; + struct ScopedCommitFlag + { + explicit ScopedCommitFlag (std::atomic& flagIn) + : flag (flagIn) + { + flag.store (true); + } + + ~ScopedCommitFlag() + { + flag.store (false); + } + + std::atomic& flag; + }; + struct DelayLine { void initialise (GraphSignalType signalTypeIn, int numChannels, int delaySamplesIn, int maxBlockSize, size_t midiReserveBytes = 4096) @@ -198,56 +222,6 @@ class AudioGraphProcessor::Pimpl int nodeIndex = -1; }; - struct SavedNodeState - { - AudioGraphNodeID id; - AudioGraphNodeProperties properties; - MemoryBlock state; - }; - - struct EndpointDescriptor - { - bool valid = false; - GraphSignalType type = GraphSignalType::audio; - int channels = 0; - }; - - static EndpointDescriptor describeNodeEndpoint (const AudioProcessor& processor, const AudioGraphEndpoint& endpoint) - { - const auto buses = endpoint.getKind() == AudioGraphEndpoint::Kind::nodeInput - ? processor.getBusLayout().getInputBuses() - : processor.getBusLayout().getOutputBuses(); - - if (! isValidBusIndex (buses, endpoint.getBusIndex())) - return {}; - - const auto& bus = buses[static_cast (endpoint.getBusIndex())]; - return { true, - toSignalType (bus.getType()), - bus.getType() == AudioBus::Type::Audio ? bus.getNumChannels() : 0 }; - } - - static bool connectionEndpointIsStillCompatible (const AudioGraphEndpoint& endpoint, - AudioGraphNodeID replacedNodeID, - const AudioProcessor* oldProcessor, - const AudioProcessor* newProcessor) - { - if (endpoint.getNodeID() != replacedNodeID) - return true; - - if (endpoint.getKind() != AudioGraphEndpoint::Kind::nodeInput - && endpoint.getKind() != AudioGraphEndpoint::Kind::nodeOutput) - return true; - - const auto oldDescriptor = describeNodeEndpoint (*oldProcessor, endpoint); - const auto newDescriptor = describeNodeEndpoint (*newProcessor, endpoint); - - return oldDescriptor.valid - && newDescriptor.valid - && oldDescriptor.type == newDescriptor.type - && oldDescriptor.channels == newDescriptor.channels; - } - class WorkerThread final : public Thread { public: @@ -260,181 +234,45 @@ class AudioGraphProcessor::Pimpl 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 replaceNodeProcessor (AudioGraphNodeID nodeID, - std::unique_ptr processor, - AudioGraphNodeProperties properties) - { - if (! nodeID.isValid()) - return Result::fail ("Audio graph node ID is invalid"); - - if (processor == nullptr) - return Result::fail ("Audio graph replacement processor is empty"); - - 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 Result::fail ("Audio graph node does not exist"); - - auto replacement = std::shared_ptr (std::move (processor)); - - if (properties.name.isEmpty()) - properties.name = replacement->getName(); - - auto& node = *nodeIterator; - auto oldProcessor = node.processor; - - modelConnections.erase (std::remove_if (modelConnections.begin(), modelConnections.end(), [nodeID, &oldProcessor, &replacement] (const AudioGraphConnection& connection) - { - return ! connectionEndpointIsStillCompatible (connection.source, nodeID, oldProcessor.get(), replacement.get()) - || ! connectionEndpointIsStillCompatible (connection.destination, nodeID, oldProcessor.get(), replacement.get()); - }), - modelConnections.end()); - - node.processor = std::move (replacement); - node.properties = std::move (properties); - node.preparedSampleRate = 0.0f; - node.preparedBlockSize = 0; - node.prepared = false; - - ++modelRevision; - dirty.store (true); - return Result::ok(); - } - - 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); + const ScopedCommitFlag scopedCommitFlag (commitInProgress); std::vector nodesSnapshot; std::vector connectionsSnapshot; - uint64_t snapshotRevision = 0; - + const auto snapshot = model->createSnapshot(); + const auto snapshotTopologyRevision = snapshot.topologyRevision; + const auto snapshotLatencyChangeCounter = latencyChangeCounter.load(); + + if (snapshotTopologyRevision == lastCommittedTopologyRevision.load() + && hasCompiledPlan() + && lastCompiledSampleRate == sampleRate + && lastCompiledMaxBlockSize == maxBlockSize + && snapshotLatencyChangeCounter == lastCommittedLatencyChangeCounter.load()) { - const std::lock_guard lock (modelMutex); - - nodesSnapshot = modelNodes; - connectionsSnapshot = modelConnections; - snapshotRevision = modelRevision; + return Result::ok(); } + nodesSnapshot.reserve (snapshot.nodes.size()); + for (const auto& node : snapshot.nodes) + nodesSnapshot.push_back ({ node.id, node.processor, node.properties }); + + connectionsSnapshot = snapshot.connections; + 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) + const auto stateIterator = preparedNodes.find (node.id.getRawID()); + const bool isPrepared = stateIterator != preparedNodes.end() + && stateIterator->second.prepared + && stateIterator->second.sampleRate == sampleRate + && stateIterator->second.blockSize == maxBlockSize + && stateIterator->second.processor.lock() == node.processor; + + if (! isPrepared) { node.processor->setPlayHead (owner.getPlayHead()); node.processor->setPlaybackConfiguration (sampleRate, maxBlockSize); @@ -447,31 +285,16 @@ class AudioGraphProcessor::Pimpl 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; - } - } - } + for (const auto& node : nodesSnapshot) + preparedNodes[node.id.getRawID()] = { node.processor, sampleRate, maxBlockSize, true }; storeStats (compiled->stats); latestLatencySamples.store (compiled->graphLatencySamples); - dirty.store (! snapshotIsCurrent); + lastCommittedTopologyRevision.store (snapshotTopologyRevision); + lastCommittedLatencyChangeCounter.store (snapshotLatencyChangeCounter); + lastCompiledSampleRate = sampleRate; + lastCompiledMaxBlockSize = maxBlockSize; + synchronizeNodeListeners (nodesSnapshot); delete pendingPlan.exchange (compiled.release()); // TODO - make it so we don't use delete deleteRetiredPlans(); @@ -490,16 +313,33 @@ class AudioGraphProcessor::Pimpl void releaseResources() { - const std::lock_guard lock (modelMutex); + const auto snapshot = model->createSnapshot(); - for (auto& node : modelNodes) + for (const auto& node : snapshot.nodes) { - if (node.processor != nullptr && node.prepared) + const auto stateIterator = preparedNodes.find (node.id.getRawID()); + const bool isPrepared = stateIterator != preparedNodes.end() + && stateIterator->second.prepared + && stateIterator->second.processor.lock() == node.processor; + + if (node.processor != nullptr && isPrepared) node.processor->releaseResources(); + } + + preparedNodes.clear(); + lastCompiledSampleRate = 0.0f; + lastCompiledMaxBlockSize = 0; + } - node.prepared = false; - node.preparedSampleRate = 0.0f; - node.preparedBlockSize = 0; + void audioProcessorChanged (AudioProcessor*, const AudioProcessor::ChangeDetails& details) override + { + if (details.latencyChanged) + { + latencyChangeCounter.fetch_add (1); + owner.updateHostDisplay (AudioProcessor::ChangeDetails().withLatencyChanged (true)); + + if (! commitInProgress.load()) + ignoreUnused (commitChanges()); } } @@ -613,221 +453,25 @@ class AudioGraphProcessor::Pimpl 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())); + const auto previousSnapshot = model->createSnapshot(); + const auto restoreResult = model->restoreFromXml (xml); - if (const auto result = readNodeProperties (*nodeElement, savedNode.properties); result.failed()) - return result; + if (restoreResult.failed()) + return restoreResult; - if (const auto result = readBase64Element (*nodeElement, "state", savedNode.state); result.failed()) - return result; + const auto commitResult = commitChanges(); - 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"); + if (commitResult.failed()) + model->restoreSnapshot (previousSnapshot); - 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)); + return commitResult; } Result saveStateIntoMemory (MemoryBlock& memoryBlock) { - auto xml = createXml(); + auto xml = model->createXml(); if (! xml) return Result::fail (xml.getErrorMessage()); @@ -851,247 +495,6 @@ class AudioGraphProcessor::Pimpl 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) @@ -1337,7 +740,8 @@ class AudioGraphProcessor::Pimpl node.inputLatencySamples = jmax (node.inputLatencySamples, getSourceLatency (graph, graph.connections[static_cast (connectionIndex)])); - node.outputLatencySamples = node.inputLatencySamples + jmax (0, node.processor->getLatencySamples()); + const auto nodeLatencySamples = jmax (0, node.processor->getLatencySamples()); + node.outputLatencySamples = node.inputLatencySamples + nodeLatencySamples; } for (const auto connectionIndex : graph.graphOutputConnections) @@ -1659,16 +1063,64 @@ class AudioGraphProcessor::Pimpl } } + bool hasCompiledPlan() const noexcept + { + return currentPlan != nullptr || pendingPlan.load() != nullptr; + } + + void synchronizeNodeListeners (const std::vector& nodesSnapshot) + { + for (auto iterator = listenedProcessors.begin(); iterator != listenedProcessors.end();) + { + const auto nodeIterator = std::find_if (nodesSnapshot.begin(), nodesSnapshot.end(), [id = iterator->first] (const ModelNode& node) + { + return node.id.getRawID() == id; + }); + + auto processor = iterator->second.lock(); + const bool shouldKeep = nodeIterator != nodesSnapshot.end() + && processor != nullptr + && nodeIterator->processor == processor; + + if (shouldKeep) + { + ++iterator; + continue; + } + + if (processor != nullptr) + processor->removeListener (this); + + iterator = listenedProcessors.erase (iterator); + } + + for (const auto& node : nodesSnapshot) + { + if (node.processor == nullptr || listenedProcessors.find (node.id.getRawID()) != listenedProcessors.end()) + continue; + + node.processor->addListener (this); + listenedProcessors[node.id.getRawID()] = node.processor; + } + } + + void removeNodeListeners() + { + for (auto& [_, processorReference] : listenedProcessors) + if (auto processor = processorReference.lock()) + processor->removeListener (this); + + listenedProcessors.clear(); + } + AudioGraphProcessor& owner; + std::shared_ptr model; 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 lastCommittedTopologyRevision { 0 }; + std::atomic latencyChangeCounter { 0 }; + std::atomic lastCommittedLatencyChangeCounter { 0 }; + std::atomic commitInProgress { false }; std::atomic desiredWorkerThreads { 0 }; std::atomic latestLatencySamples { 0 }; std::atomic latestScratchAudioBuffers { 0 }; @@ -1677,10 +1129,13 @@ class AudioGraphProcessor::Pimpl std::atomic latestTotalCompensationSamples { 0 }; std::atomic latestMaxPreallocatedChannels { 0 }; std::atomic latestMaxPreallocatedBlockSize { 0 }; - AudioGraphProcessor::NodeFactory nodeFactory; + std::unordered_map preparedNodes; + std::unordered_map> listenedProcessors; AudioWorkgroup workgroup; float sampleRate = 44100.0f; int maxBlockSize = 1024; + float lastCompiledSampleRate = 0.0f; + int lastCompiledMaxBlockSize = 0; std::atomic pendingPlan { nullptr }; std::atomic retiredPlans { nullptr }; CompiledGraph* currentPlan = nullptr; @@ -1740,55 +1195,18 @@ AudioBusLayout AudioGraphProcessor::createDefaultBusLayout() { AudioBus ("Output", AudioBus::Type::Audio, AudioBus::Direction::Output, 2) }); } -AudioGraphProcessor::AudioGraphProcessor (AudioBusLayout busLayout) +AudioGraphProcessor::AudioGraphProcessor (std::shared_ptr model, + AudioBusLayout busLayout) : AudioProcessor ("Audio Graph", std::move (busLayout)) - , pimpl (std::make_unique (*this)) + , pimpl (std::make_unique (*this, std::move (model))) { } 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::replaceNodeProcessor (AudioGraphNodeID nodeID, - std::unique_ptr processor, - AudioGraphNodeProperties properties) -{ - return pimpl->replaceNodeProcessor (nodeID, std::move (processor), std::move (properties)); -} - -Result AudioGraphProcessor::addConnection (const AudioGraphConnection& connection) -{ - return pimpl->addConnection (connection); -} - -bool AudioGraphProcessor::removeConnection (const AudioGraphConnection& connection) +std::shared_ptr AudioGraphProcessor::getModel() const noexcept { - return pimpl->removeConnection (connection); -} - -std::vector AudioGraphProcessor::getConnections() const -{ - return pimpl->getConnections(); -} - -void AudioGraphProcessor::clear() -{ - pimpl->clear(); + return pimpl->model; } Result AudioGraphProcessor::commitChanges() @@ -1818,42 +1236,13 @@ AudioGraphAllocationStats AudioGraphProcessor::getAllocationStats() const noexce 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)); + return pimpl->model->getTopologyRevision() != pimpl->lastCommittedTopologyRevision.load() + || pimpl->latencyChangeCounter.load() != pimpl->lastCommittedLatencyChangeCounter.load(); } std::unique_ptr AudioGraphProcessor::createXml() const { - auto result = pimpl->createXml(); + auto result = pimpl->model->createXml(); if (! result) return nullptr; diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h index 7a0fcd2e0..a5254febc 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h @@ -26,68 +26,41 @@ 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. + Topology 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. Child processor + latency notifications trigger a rebuild so plugin latency changes can update + delay compensation. Metadata edits such as node positions and properties + are saved by the model without invalidating the compiled 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()); + + /** Constructs an audio graph that compiles and processes an external graph model. */ + explicit AudioGraphProcessor (std::shared_ptr model, + 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); - - /** - Replaces a processor node while preserving its node identifier. - - Connections that reference an input or output bus whose type or channel - count no longer matches the replacement processor are removed from the - control-thread graph model. The caller must still call commitChanges() - to publish the new processing plan. - */ - Result replaceNodeProcessor (AudioGraphNodeID nodeID, - std::unique_ptr processor, - AudioGraphNodeProperties properties); - - /** 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(); + /** Returns the editable graph model consumed by this processor. */ + std::shared_ptr getModel() const noexcept; //============================================================================== - /** Validates, compiles, and publishes the current graph model. */ + + /** Validates, compiles, and publishes the current graph topology when needed. */ Result commitChanges(); - /** Returns true when graph edits have not yet been committed. */ + /** Returns true when topology edits have not yet been committed. */ bool hasUncommittedChanges() const noexcept; //============================================================================== @@ -103,25 +76,6 @@ class YUP_API AudioGraphProcessor final : public AudioProcessor /** 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; diff --git a/modules/yup_audio_graph/yup_audio_graph.cpp b/modules/yup_audio_graph/yup_audio_graph.cpp index 5f3576af7..9280970af 100644 --- a/modules/yup_audio_graph/yup_audio_graph.cpp +++ b/modules/yup_audio_graph/yup_audio_graph.cpp @@ -31,4 +31,5 @@ #include "yup_audio_graph.h" //============================================================================== +#include "graph/yup_AudioGraphModel.cpp" #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 index 6e82336e9..16aa57ad9 100644 --- a/modules/yup_audio_graph/yup_audio_graph.h +++ b/modules/yup_audio_graph/yup_audio_graph.h @@ -33,7 +33,7 @@ license: ISC minimumCppStandard: 17 - dependencies: yup_audio_processors + dependencies: yup_audio_processors yup_data_model searchpaths: native END_YUP_MODULE_DECLARATION @@ -56,6 +56,7 @@ #include #include +#include //============================================================================== #include "graph/yup_AudioGraphNodeID.h" @@ -63,4 +64,5 @@ #include "graph/yup_AudioGraphConnection.h" #include "graph/yup_AudioGraphAllocationStats.h" #include "graph/yup_AudioGraphNodeProperties.h" +#include "graph/yup_AudioGraphModel.h" #include "graph/yup_AudioGraphProcessor.h" diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp index 07de1dfec..a3ae98ff4 100644 --- a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp +++ b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp @@ -41,8 +41,8 @@ const Identifier AudioGraphComponent::Style::backgroundColorId ("audioGraphBackg const Identifier AudioGraphComponent::Style::gridColorId ("audioGraphGrid"); //============================================================================== -AudioGraphComponent::AudioGraphComponent (std::shared_ptr graphIn) - : graph (std::move (graphIn)) +AudioGraphComponent::AudioGraphComponent (std::shared_ptr modelIn) + : model (std::move (modelIn)) { setOpaque (true); setWantsKeyboardFocus (true); @@ -50,6 +50,12 @@ AudioGraphComponent::AudioGraphComponent (std::shared_ptr g enableRenderingUnclipped (true); } +AudioGraphComponent::AudioGraphComponent (std::shared_ptr graphIn) + : AudioGraphComponent (graphIn != nullptr ? graphIn->getModel() : nullptr) +{ + graph = std::move (graphIn); +} + AudioGraphComponent::~AudioGraphComponent() { for (auto& node : nodes) @@ -362,7 +368,7 @@ void AudioGraphComponent::mouseDown (const MouseEvent& event) if (auto connection = hitTestConnection (screenPos)) { - removeConnectionAndCommit (*connection); + requestConnectionRemoval (*connection); return; } @@ -375,16 +381,12 @@ void AudioGraphComponent::mouseDown (const MouseEvent& event) if (node.view->getLocalBounds().contains (localPos)) { - if (onNodeContextMenu != nullptr) - onNodeContextMenu (node.nodeID, screenToCanvas (screenPos)); - + listeners.call (&Listener::nodeContextMenu, node.nodeID, screenToCanvas (screenPos)); return; } } - if (onCanvasContextMenu != nullptr) - onCanvasContextMenu (screenToCanvas (screenPos)); - + listeners.call (&Listener::canvasContextMenu, screenToCanvas (screenPos)); return; } @@ -513,10 +515,25 @@ void AudioGraphComponent::mouseUp (const MouseEvent& event) const auto& item = nodes[static_cast (draggedNodeIndex)]; if (item.kind == NodeItem::Kind::processor) - listeners.call ([&] (Listener& listener) + { + auto& draggedItem = nodes[static_cast (draggedNodeIndex)]; + bool accepted = true; + + if (onNodeMoveRequested != nullptr) + accepted = onNodeMoveRequested (draggedItem.nodeID, dragStartNodePosition, draggedItem.canvasPosition); + + if (! accepted) { - listener.nodeViewMoved (item.nodeID, item.canvasPosition); - }); + draggedItem.canvasPosition = dragStartNodePosition; + updateNodeBounds(); + repaint(); + interaction = Interaction::idle; + draggedNodeIndex = -1; + break; + } + + listeners.call (&Listener::nodeViewMoved, draggedItem.nodeID, draggedItem.canvasPosition); + } } interaction = Interaction::idle; @@ -537,7 +554,7 @@ void AudioGraphComponent::mouseUp (const MouseEvent& event) void AudioGraphComponent::mouseDoubleClick (const MouseEvent& event) { - if (onNodeDoubleClicked == nullptr) + if (listeners.isEmpty()) return; const auto screenPos = eventPositionInThisComponent (event); @@ -551,7 +568,7 @@ void AudioGraphComponent::mouseDoubleClick (const MouseEvent& event) if (node.view->getLocalBounds().contains (localPos)) { - onNodeDoubleClicked (node.nodeID); + listeners.call (&Listener::nodeDoubleClicked, node.nodeID); return; } } @@ -736,10 +753,10 @@ std::optional AudioGraphComponent::hitTestEndp std::optional AudioGraphComponent::hitTestConnection (Point screenPos) const { - if (graph == nullptr) + if (model == nullptr) return {}; - for (const auto& connection : graph->getConnections()) + for (const auto& connection : model->getConnections()) { const auto start = getEndpointScreenPosition (connection.source); const auto end = getEndpointScreenPosition (connection.destination); @@ -862,85 +879,51 @@ Point AudioGraphComponent::getPendingWireEndPosition() const noexcept bool AudioGraphComponent::tryConnect (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) { - if (graph == nullptr || ! isCompatiblePair (first, second)) + if (model == nullptr || ! isCompatiblePair (first, second)) return false; const auto connection = makeConnection (first, second); - if (graph->addConnection (connection).failed()) + if (onConnectionRequested == nullptr || ! onConnectionRequested (connection)) return false; - if (graph->commitChanges().failed()) - { - graph->removeConnection (connection); - graph->commitChanges(); - return false; - } + listeners.call (&Listener::connectionAdded, connection); - listeners.call ([&] (Listener& listener) - { - listener.connectionAdded (connection); - }); repaint(); return true; } -bool AudioGraphComponent::removeConnectionAndCommit (const AudioGraphConnection& connection) +bool AudioGraphComponent::requestConnectionRemoval (const AudioGraphConnection& connection) { - if (graph == nullptr || ! graph->removeConnection (connection)) + if (model == nullptr || onConnectionRemovalRequested == nullptr || ! onConnectionRemovalRequested (connection)) return false; - if (graph->commitChanges().failed()) - { - graph->addConnection (connection); - graph->commitChanges(); - return false; - } + listeners.call (&Listener::connectionRemoved, connection); - listeners.call ([&] (Listener& listener) - { - listener.connectionRemoved (connection); - }); repaint(); return true; } void AudioGraphComponent::removeConnectionsForEndpoint (const AudioGraphEndpoint& endpoint) { - if (graph == nullptr) + if (model == nullptr || onEndpointConnectionsRemovalRequested == nullptr) return; - const auto connections = graph->getConnections(); + const auto connections = model->getConnections(); std::vector removedConnections; for (const auto& connection : connections) - { if (connection.source == endpoint || connection.destination == endpoint) - { - if (graph->removeConnection (connection)) - removedConnections.push_back (connection); - } - } + removedConnections.push_back (connection); if (removedConnections.empty()) return; - if (graph->commitChanges().failed()) - { - for (const auto& connection : removedConnections) - graph->addConnection (connection); - - graph->commitChanges(); + if (! onEndpointConnectionsRemovalRequested (endpoint)) return; - } for (const auto& connection : removedConnections) - { - listeners.call ([&] (Listener& listener) - { - listener.connectionRemoved (connection); - }); - } + listeners.call (&Listener::connectionRemoved, connection); repaint(); } @@ -952,8 +935,7 @@ bool AudioGraphComponent::isCompatiblePair (const AudioGraphEndpoint& first, con AudioGraphConnection AudioGraphComponent::makeConnection (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept { - return first.isSource() ? AudioGraphConnection { first, second } - : AudioGraphConnection { second, first }; + return first.isSource() ? AudioGraphConnection { first, second } : AudioGraphConnection { second, first }; } Point AudioGraphComponent::cubicPoint (Point p0, Point p1, Point p2, Point p3, float t) const noexcept diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h index 68bb858da..2fdf084b1 100644 --- a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h +++ b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h @@ -24,12 +24,13 @@ namespace yup //============================================================================== /** - Zoomable and pannable editor canvas for an AudioGraphProcessor. + Zoomable and pannable editor canvas for an AudioGraphModel. - The component owns only the visual node views. The AudioGraphProcessor remains - the source of truth for nodes and connections. + The component owns only the visual node views. The AudioGraphModel remains + the source of truth for nodes and connections, while graph edits are routed + through request callbacks supplied by the user/editor layer. - @see AudioGraphNodeView, AudioGraphProcessor + @see AudioGraphNodeView, AudioGraphModel, AudioGraphProcessor */ class YUP_API AudioGraphComponent : public Component { @@ -44,27 +45,17 @@ class YUP_API AudioGraphComponent : public Component 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&) {} + //============================================================================== + /** Creates an editor for a graph model. */ + explicit AudioGraphComponent (std::shared_ptr model); - /** Called when a node view is released after dragging. */ - virtual void nodeViewMoved (AudioGraphNodeID, Point newCanvasPos) {} - }; - - /** Creates an editor for graph. */ + /** Creates an editor for a processor's model. */ 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, @@ -84,9 +75,7 @@ class YUP_API AudioGraphComponent : public Component /** 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); @@ -105,6 +94,7 @@ class YUP_API AudioGraphComponent : public Component /** 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); @@ -117,12 +107,14 @@ class YUP_API AudioGraphComponent : public Component /** 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(); @@ -153,30 +145,58 @@ class YUP_API AudioGraphComponent : public Component /** @internal Returns the current screen endpoint for the pending connection wire. */ Point getPendingWireEndPosition() const noexcept; + //============================================================================== + /** 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) {} + + /** Called when the user requests a context menu on a node. */ + virtual void nodeContextMenu (AudioGraphNodeID, Point canvasPos) {} + + /** Called when the user double-clicks on a node. */ + virtual void nodeDoubleClicked (AudioGraphNodeID) {} + + /** Called when the user requests a context menu on the canvas. */ + virtual void canvasContextMenu (Point canvasPos) {} + }; + /** 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 requests a new connection. Return true after accepting the edit. */ + std::function onConnectionRequested; - /** - 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 requests a connection removal. Return true after accepting the edit. */ + std::function onConnectionRemovalRequested; + + /** Called when the user requests removal of every connection touching an endpoint. Return true after accepting the edit. */ + std::function onEndpointConnectionsRemovalRequested; + + /** Called when the user requests a node move. Return true after accepting the edit. */ + std::function oldCanvasPos, Point newCanvasPos)> onNodeMoveRequested; + + //============================================================================== + /** @internal Returns the graph processor currently being edited. */ + const AudioGraphProcessor* getGraphProcessor() const noexcept { return graph.get(); } - /** - Called when the user double-clicks on a node body. - nodeID identifies the clicked node. - */ - std::function onNodeDoubleClicked; + /** @internal Returns the graph model currently being edited. */ + const AudioGraphModel* getGraphModel() const noexcept { return model.get(); } + //============================================================================== /** @internal */ void resized() override; /** @internal */ @@ -241,7 +261,7 @@ class YUP_API AudioGraphComponent : public Component std::optional hitTestConnection (Point screenPos) const; bool tryConnect (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second); - bool removeConnectionAndCommit (const AudioGraphConnection& connection); + bool requestConnectionRemoval (const AudioGraphConnection& connection); void removeConnectionsForEndpoint (const AudioGraphEndpoint& endpoint); bool isCompatiblePair (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept; @@ -250,6 +270,7 @@ class YUP_API AudioGraphComponent : public Component 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 model; std::shared_ptr graph; std::vector nodes; ListenerList listeners; diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.h b/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.h index 37d1b2e94..29dd3c98c 100644 --- a/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.h +++ b/modules/yup_audio_gui/graph/yup_AudioGraphNodeView.h @@ -35,20 +35,6 @@ namespace yup 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 { @@ -99,12 +85,14 @@ class YUP_API AudioGraphNodeView : public Component 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; } @@ -135,6 +123,7 @@ class YUP_API AudioGraphNodeView : public Component /** 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; @@ -142,8 +131,9 @@ class YUP_API AudioGraphNodeView : public Component virtual int getPreferredWidth() const; /** Returns the computed preferred height in canvas units. */ - int getPreferredHeight() const; + virtual int getPreferredHeight() const; + //============================================================================== /** Returns the local center of an input port. */ Point getInputPortCenter (int busIndex) const; @@ -153,18 +143,36 @@ class YUP_API AudioGraphNodeView : public Component /** 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); + //============================================================================== + /** Returns the port at localPos when it is within the port radius. */ + virtual std::optional hitTestPort (Point localPos) const; + + //============================================================================== /** @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; } + //============================================================================== + /** 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; + }; + + //============================================================================== /** @internal */ void paint (Graphics& g) override; diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm index d20d10dff..d58a7cb5e 100644 --- a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_AUv2.mm @@ -342,10 +342,12 @@ void updateCocoaViewFrame() : AudioPluginInstance(desc, std::move(busLayout)), audioUnit(unit) { buildParameterList(); + installLatencyListener(); } ~AUv2Instance() override { + removeLatencyListener(); removeParameterListeners(); releaseResources(); @@ -688,6 +690,27 @@ bool hasEditor() const override return nullptr; } + int getLatencySamples() override + { + if (audioUnit == nullptr) + return 0; + + Float64 latencySeconds = 0.0; + UInt32 propertySize = sizeof(latencySeconds); + + if (AudioUnitGetProperty(audioUnit, + kAudioUnitProperty_Latency, + kAudioUnitScope_Global, + 0, + &latencySeconds, + &propertySize) != noErr) + { + return 0; + } + + return jmax(0, roundToInt(latencySeconds * static_cast(getSampleRate()))); + } + //============================================================================== static std::unique_ptr create(const AudioPluginDescription& desc, @@ -1159,6 +1182,32 @@ void removeParameterListeners() } } + void installLatencyListener() + { + if (audioUnit != nullptr) + AudioUnitAddPropertyListener(audioUnit, kAudioUnitProperty_Latency, latencyPropertyChanged, this); + } + + void removeLatencyListener() + { + if (audioUnit != nullptr) + AudioUnitRemovePropertyListenerWithUserData(audioUnit, kAudioUnitProperty_Latency, latencyPropertyChanged, this); + } + + static void latencyPropertyChanged(void* userData, + AudioUnit, + AudioUnitPropertyID propertyID, + AudioUnitScope, + AudioUnitElement) + { + if (propertyID != kAudioUnitProperty_Latency) + return; + + auto* instance = static_cast (userData); + if (instance != nullptr) + instance->setLatencySamples(instance->getLatencySamples()); + } + AudioUnit audioUnit = nullptr; AUEventListenerRef eventListener = nullptr; int currentPreset = 0; diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp index 9497c71ad..d888c664b 100644 --- a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_CLAP.cpp @@ -75,6 +75,8 @@ struct YUPCLAPHost { clap_host_t host {}; clap_host_note_ports_t notePorts {}; + clap_host_latency_t latency {}; + std::function latencyChanged; String hostName; String hostVendor; String hostVersion; @@ -95,6 +97,12 @@ struct YUPCLAPHost return CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI; }; notePorts.rescan = [] (const clap_host_t*, uint32_t) {}; + latency.changed = [] (const clap_host_t* host) + { + auto* self = static_cast (host->host_data); + if (self->latencyChanged != nullptr) + self->latencyChanged(); + }; host.get_extension = [] (const clap_host_t* host, const char* extensionId) -> const void* { @@ -102,6 +110,9 @@ struct YUPCLAPHost if (std::strcmp (extensionId, CLAP_EXT_NOTE_PORTS) == 0) return &self->notePorts; + if (std::strcmp (extensionId, CLAP_EXT_LATENCY) == 0) + return &self->latency; + return nullptr; }; host.request_restart = [] (const clap_host_t*) {}; @@ -292,6 +303,11 @@ class CLAPInstance : public AudioPluginInstance , yupHost (std::move (clapHost)) , clapPlugin (plugin) { + yupHost->latencyChanged = [this] + { + setLatencySamples (getLatencySamples()); + }; + buildParameterList(); setNonRealtime (context.isNonRealtime); } @@ -299,6 +315,7 @@ class CLAPInstance : public AudioPluginInstance ~CLAPInstance() override { releaseResources(); + yupHost->latencyChanged = nullptr; if (clapPlugin != nullptr) { @@ -483,6 +500,14 @@ class CLAPInstance : public AudioPluginInstance bool hasEditor() const override { return false; } + int getLatencySamples() override + { + auto* latencyExt = reinterpret_cast ( + clapPlugin->get_extension (clapPlugin, CLAP_EXT_LATENCY)); + + return latencyExt != nullptr ? static_cast (latencyExt->get (clapPlugin)) : 0; + } + //============================================================================== static std::unique_ptr create (const AudioPluginDescription& desc, diff --git a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp index a56b37e5e..a686776e1 100644 --- a/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp +++ b/modules/yup_audio_plugin_host/native/yup_AudioPluginInstance_VST3.cpp @@ -591,6 +591,8 @@ struct VST3Module class HostComponentHandler : public Vst::IComponentHandler { public: + using RestartCallback = std::function; + HostComponentHandler() { FUNKNOWN_CTOR @@ -609,9 +611,23 @@ class HostComponentHandler : public Vst::IComponentHandler tresult PLUGIN_API endEdit (Vst::ParamID) override { return kResultOk; } - tresult PLUGIN_API restartComponent (int32) override { return kResultOk; } + tresult PLUGIN_API restartComponent (int32 flags) override + { + if (restartCallback != nullptr) + restartCallback (flags); + + return kResultOk; + } + + void setRestartCallback (RestartCallback callback) + { + restartCallback = std::move (callback); + } DECLARE_FUNKNOWN_METHODS + +private: + RestartCallback restartCallback; }; IMPLEMENT_FUNKNOWN_METHODS (HostComponentHandler, Vst::IComponentHandler, Vst::IComponentHandler::iid) @@ -881,6 +897,12 @@ class VST3Instance : public AudioPluginInstance , vst3Controller (std::move (controller)) , vst3ControllerInitialized (controllerWasInitialized) { + if (auto* handler = static_cast (vst3ComponentHandler.get())) + handler->setRestartCallback ([this] (int32 flags) + { + handleRestartComponent (flags); + }); + connectComponentAndController(); buildParameterList(); setNonRealtime (context.isNonRealtime); @@ -891,6 +913,9 @@ class VST3Instance : public AudioPluginInstance disconnectComponentAndController(); releaseResources(); + if (auto* handler = static_cast (vst3ComponentHandler.get())) + handler->setRestartCallback (nullptr); + if (vst3Controller != nullptr) { vst3Controller->setComponentHandler (nullptr); @@ -1021,6 +1046,11 @@ class VST3Instance : public AudioPluginInstance collectOutputEvents (midiBuffer); } + int getLatencySamples() override + { + return vst3Processor != nullptr ? static_cast (vst3Processor->getLatencySamples()) : 0; + } + void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) override { ScopedNoDenormals noDenormals; @@ -1152,6 +1182,14 @@ class VST3Instance : public AudioPluginInstance //============================================================================== + void handleRestartComponent (int32 flags) + { + if ((flags & Vst::kLatencyChanged) != 0) + setLatencySamples (getLatencySamples()); + } + + //============================================================================== + static std::unique_ptr create (const AudioPluginDescription& desc, const AudioPluginHostContext& context) { diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp index 259e758ca..54e3bc5ea 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp @@ -51,6 +51,21 @@ void AudioProcessor::addParameter (AudioParameter::Ptr parameter) parameters.emplace_back (std::move (parameter)); } +void AudioProcessor::addListener (Listener* listener) +{ + listeners.add (listener); +} + +void AudioProcessor::removeListener (Listener* listener) +{ + listeners.remove (listener); +} + +void AudioProcessor::updateHostDisplay (ChangeDetails details) +{ + listeners.call (&Listener::audioProcessorChanged, this, details); +} + //============================================================================== int AudioProcessor::getNumAudioOutputs() const @@ -98,6 +113,17 @@ bool AudioProcessor::isSuspended() const //============================================================================== +void AudioProcessor::setLatencySamples (int newLatencySamples) +{ + const auto clampedLatencySamples = jmax (0, newLatencySamples); + const auto oldLatencySamples = latencySamples.exchange (clampedLatencySamples); + + if (oldLatencySamples != clampedLatencySamples) + updateHostDisplay (ChangeDetails().withLatencyChanged (true)); +} + +//============================================================================== + void AudioProcessor::setProcessingPrecision (ProcessingPrecision precision) { if (precision == ProcessingPrecision::doublePrecision && ! supportsDoublePrecisionProcessing()) diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.h b/modules/yup_audio_processors/processors/yup_AudioProcessor.h index d560a9903..ac03106d5 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.h +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.h @@ -33,6 +33,31 @@ class AudioProcessorEditor; class YUP_API AudioProcessor { public: + /** Details about a processor-level change notification. */ + struct ChangeDetails + { + /** Returns a copy of these details with the latency change flag set. */ + ChangeDetails withLatencyChanged (bool shouldBeLatencyChanged) const noexcept + { + auto copy = *this; + copy.latencyChanged = shouldBeLatencyChanged; + return copy; + } + + /** True when the processor latency may have changed. */ + bool latencyChanged = false; + }; + + /** Receives processor-level change notifications. */ + class Listener + { + public: + virtual ~Listener() = default; + + /** Called when a processor-level property changes. */ + virtual void audioProcessorChanged (AudioProcessor* processor, const ChangeDetails& details) = 0; + }; + //============================================================================== /** The floating-point precision used for processBlock() calls. */ enum class ProcessingPrecision @@ -62,6 +87,15 @@ class YUP_API AudioProcessor /** Adds a parameter. */ void addParameter (AudioParameter::Ptr parameter); + /** Adds a processor-level change listener. */ + void addListener (Listener* listener); + + /** Removes a processor-level change listener. */ + void removeListener (Listener* listener); + + /** Notifies listeners that processor-level details have changed. */ + void updateHostDisplay (ChangeDetails details); + //============================================================================== /** Returns the bus layout. */ @@ -156,7 +190,10 @@ class YUP_API AudioProcessor virtual int getTailSamples() { return 0; } - virtual int getLatencySamples() { return 0; } + virtual int getLatencySamples() { return latencySamples.load(); } + + /** Sets the processor latency in samples and notifies listeners when it changes. */ + void setLatencySamples (int newLatencySamples); //============================================================================== @@ -227,11 +264,13 @@ class YUP_API AudioProcessor std::vector parameters; std::unordered_map parameterMap; + ListenerList> listeners; AudioBusLayout busLayout; float sampleRate = 44100.0f; int samplesPerBlock = 1024; + std::atomic latencySamples { 0 }; ProcessingPrecision processingPrecision = ProcessingPrecision::singlePrecision; AudioPlayHead* playHead = nullptr; diff --git a/modules/yup_audio_processors/yup_audio_processors.h b/modules/yup_audio_processors/yup_audio_processors.h index eca653ff6..c279dd92a 100644 --- a/modules/yup_audio_processors/yup_audio_processors.h +++ b/modules/yup_audio_processors/yup_audio_processors.h @@ -42,6 +42,8 @@ #pragma once #define YUP_AUDIO_PROCESSORS_H_INCLUDED +#include + #include #if YUP_MODULE_AVAILABLE_yup_gui diff --git a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp index 6a1f400d1..25c79d932 100644 --- a/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp +++ b/modules/yup_gui/themes/theme_v1/yup_ThemeVersion1.cpp @@ -148,11 +148,12 @@ void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider else g.fillRoundedRect (sliderBounds.getCenterX() - 2.0f, sliderBounds.getY(), 4.0f, sliderBounds.getHeight(), 2.0f); - // Draw value track for bar sliders + // Draw value track + g.setFillColor (isMouseDown ? colors.thumbDown : (isMouseOver ? colors.thumbOver : colors.thumb)); + const auto sliderType = slider.getSliderType(); if (sliderType == Slider::LinearBarHorizontal || sliderType == Slider::LinearBarVertical) { - auto thumbColor = slider.isMouseOver() ? colors.thumbOver : colors.thumb; if (isHorizontal) g.fillRoundedRect (sliderBounds.getX(), sliderBounds.getCenterY() - 2.0f, sliderValue * sliderBounds.getWidth(), 4.0f, 2.0f); else @@ -164,7 +165,6 @@ void paintLinearSlider (Graphics& g, const ApplicationTheme& theme, const Slider } else { - g.setFillColor (isMouseDown ? colors.thumbDown : (isMouseOver ? colors.thumbOver : colors.thumb)); g.fillEllipse (thumbBounds); } @@ -1069,9 +1069,9 @@ void paintAudioGraphComponent (Graphics& g, const ApplicationTheme&, const Audio } } - if (const auto* processor = graph.getGraphProcessor()) + if (const auto* model = graph.getGraphModel()) { - for (const auto& connection : processor->getConnections()) + for (const auto& connection : model->getConnections()) paintAudioGraphConnection (g, graph, connection, 1.0f); } diff --git a/tests/yup_audio_graph.cpp b/tests/yup_audio_graph.cpp index 262daeec4..205296769 100644 --- a/tests/yup_audio_graph.cpp +++ b/tests/yup_audio_graph.cpp @@ -19,4 +19,5 @@ ============================================================================== */ +//#include "yup_audio_graph/yup_AudioGraphModel.cpp" #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 index 0a7ddf750..a4522c493 100644 --- a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp +++ b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp @@ -325,6 +325,32 @@ class BlockingLatencyProcessor : public TestProcessor std::atomic& shouldContinue; }; +class CountingLatencyProcessor : public TestProcessor +{ +public: + explicit CountingLatencyProcessor (std::atomic& queryCountToUse) + : TestProcessor() + , queryCount (queryCountToUse) + { + } + + int getLatencySamples() override + { + queryCount.fetch_add (1); + return latencySamples.load(); + } + + void setLatencySamplesForTest (int newLatencySamples) + { + latencySamples.store (newLatencySamples); + setLatencySamples (newLatencySamples); + } + +private: + std::atomic& queryCount; + std::atomic latencySamples { 0 }; +}; + class StatefulGainProcessor : public AudioProcessor { public: @@ -390,7 +416,7 @@ AudioGraphNodeProperties statefulGainProperties (float positionX = 0.0f, float p return properties; } -AudioGraphProcessor::NodeFactory statefulGainFactory() +AudioGraphModel::NodeFactory statefulGainFactory() { return [] (const AudioGraphNodeProperties& properties) -> ResultValue> { @@ -501,7 +527,7 @@ AudioGraphNodeProperties externalProcessorProperties (float positionX = 0.0f, fl return properties; } -AudioGraphProcessor::NodeFactory statefulGainAndExternalFactory (int& externalLoadCount, String& externalIdentifier) +AudioGraphModel::NodeFactory statefulGainAndExternalFactory (int& externalLoadCount, String& externalIdentifier) { return [&externalLoadCount, &externalIdentifier] (const AudioGraphNodeProperties& properties) -> ResultValue> { @@ -587,19 +613,20 @@ String toBase64Text (const MemoryBlock& block) bool resetGraphToSingleGainPath (AudioGraphProcessor& graph, float gain) { - graph.clear(); + auto model = graph.getModel(); + model->clear(); - const auto node = graph.addNode (std::make_unique (gain)); + const auto node = model->addNode (std::make_unique (gain)); if (! node.isValid()) return false; - if (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), - AudioGraphEndpoint::nodeInput (node, 0) }) + if (model->addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (node, 0) }) .failed()) return false; - if (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), - AudioGraphEndpoint::graphOutput (0) }) + if (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), + AudioGraphEndpoint::graphOutput (0) }) .failed()) return false; @@ -773,10 +800,11 @@ class MixedDelayingProcessor : public AudioProcessor TEST (AudioGraphProcessorTests, CommitPreparesNodes) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); auto processor = std::make_unique(); auto* processorPtr = processor.get(); - const auto node = graph.addNode (std::move (processor)); + const auto node = model->addNode (std::move (processor)); EXPECT_TRUE (node.isValid()); @@ -791,10 +819,11 @@ TEST (AudioGraphProcessorTests, CommitPreparesNodes) TEST (AudioGraphProcessorTests, RejectsMissingNodeConnection) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); - const auto result = graph.addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID::invalid(), 0), - AudioGraphEndpoint::graphOutput (0) }); + const auto result = model->addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID::invalid(), 0), + AudioGraphEndpoint::graphOutput (0) }); EXPECT_TRUE (result.wasOk()); EXPECT_TRUE (graph.commitChanges().failed()); @@ -802,15 +831,16 @@ TEST (AudioGraphProcessorTests, RejectsMissingNodeConnection) TEST (AudioGraphProcessorTests, RejectsCycles) { - AudioGraphProcessor graph; - const auto first = graph.addNode (std::make_unique()); - const auto second = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto first = model->addNode (std::make_unique()); + const auto second = model->addNode (std::make_unique()); - EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), - AudioGraphEndpoint::nodeInput (second, 0) }) + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), + AudioGraphEndpoint::nodeInput (second, 0) }) .wasOk()); - EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), - AudioGraphEndpoint::nodeInput (first, 0) }) + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), + AudioGraphEndpoint::nodeInput (first, 0) }) .wasOk()); EXPECT_TRUE (graph.commitChanges().failed()); @@ -818,13 +848,14 @@ TEST (AudioGraphProcessorTests, RejectsCycles) 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)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto first = model->addNode (std::make_unique (2.0f)); + const auto second = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -839,12 +870,13 @@ TEST (AudioGraphProcessorTests, ProcessesSerialAudioChain) TEST (AudioGraphProcessorTests, ProcessesBlocksLargerThanPreparedMaximumInChunks) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); 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()); + const auto node = model->addNode (std::make_unique (0.5f)); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 40); @@ -863,11 +895,12 @@ TEST (AudioGraphProcessorTests, ProcessesBlocksLargerThanPreparedMaximumInChunks TEST (AudioGraphProcessorTests, PreservesMidiEventsInBlocksLargerThanPreparedMaximum) { - AudioGraphProcessor graph (midiLayout()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, midiLayout()); graph.prepareToPlay (48000.0f, 16); - EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), - AudioGraphEndpoint::graphOutput (0) }) + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::graphOutput (0) }) .wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); @@ -889,14 +922,15 @@ TEST (AudioGraphProcessorTests, PreservesMidiEventsInBlocksLargerThanPreparedMax 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)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto left = model->addNode (std::make_unique (0.25f)); + const auto right = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (left, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (right, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (left, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (right, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -913,11 +947,12 @@ 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); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, 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()); + const auto node = model->addNode (std::make_unique()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (0, 32); @@ -932,14 +967,15 @@ TEST (AudioGraphProcessorTests, PreservesMidiTimestamps) 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)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto dry = model->addNode (std::make_unique (1.0f, 0)); + const auto latent = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latent, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (latent, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -956,21 +992,23 @@ TEST (AudioGraphProcessorTests, CompensatesShorterParallelPaths) TEST (AudioGraphProcessorTests, NullNodeIsRejected) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); - EXPECT_FALSE (graph.addNode (nullptr).isValid()); - EXPECT_TRUE (graph.hasUncommittedChanges()); + EXPECT_FALSE (model->addNode (nullptr).isValid()); + EXPECT_FALSE (graph.hasUncommittedChanges()); } TEST (AudioGraphProcessorTests, RejectsInvalidEndpointDirectionImmediately) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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) }); + const auto badSource = model->addConnection ({ AudioGraphEndpoint::nodeInput (node, 0), + AudioGraphEndpoint::graphOutput (0) }); + const auto badDestination = model->addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeOutput (node, 0) }); EXPECT_TRUE (badSource.failed()); EXPECT_TRUE (badDestination.failed()); @@ -978,62 +1016,67 @@ TEST (AudioGraphProcessorTests, RejectsInvalidEndpointDirectionImmediately) TEST (AudioGraphProcessorTests, RejectsDuplicateConnectionsImmediately) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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()); + EXPECT_TRUE (model->addConnection (connection).wasOk()); + EXPECT_TRUE (model->addConnection (connection).failed()); } TEST (AudioGraphProcessorTests, RejectsInvalidBusIndexAtCommit) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->addNode (std::make_unique()); - EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::graphInput (0), - AudioGraphEndpoint::nodeInput (node, 99) }) + EXPECT_TRUE (model->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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto audioNode = model->addNode (std::make_unique()); + const auto midiNode = model->addNode (std::make_unique()); - EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (audioNode, 0), - AudioGraphEndpoint::nodeInput (midiNode, 0) }) + EXPECT_TRUE (model->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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto stereoNode = model->addNode (std::make_unique()); + const auto monoNode = model->addNode (std::make_unique()); - EXPECT_TRUE (graph.addConnection ({ AudioGraphEndpoint::nodeOutput (stereoNode, 0), - AudioGraphEndpoint::nodeInput (monoNode, 0) }) + EXPECT_TRUE (model->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)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto first = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (model->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()); + const auto second = model->addNode (std::make_unique (0.25f)); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.hasUncommittedChanges()); AudioBuffer audio (2, 16); @@ -1045,17 +1088,61 @@ TEST (AudioGraphProcessorTests, MissingCommitKeepsPreviousPlan) EXPECT_FLOAT_EQ (0.5f, audio.getReadPointer (0)[0]); } +TEST (AudioGraphProcessorTests, ExternalTopologyEditsDriveDirtyRevision) +{ + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + std::atomic latencyQueryCount { 0 }; + + EXPECT_EQ (model, graph.getModel()); + EXPECT_FALSE (graph.hasUncommittedChanges()); + + const auto node = model->addNode (std::make_unique (latencyQueryCount)); + ASSERT_TRUE (node.isValid()); + EXPECT_TRUE (graph.hasUncommittedChanges()); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_FALSE (graph.hasUncommittedChanges()); + const auto latencyQueriesAfterCommit = latencyQueryCount.load(); + EXPECT_GT (latencyQueriesAfterCommit, 0); + + EXPECT_TRUE (model->setNodePosition (node, 20.0f, 40.0f)); + EXPECT_FALSE (graph.hasUncommittedChanges()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_FALSE (graph.hasUncommittedChanges()); + + auto properties = model->getNodeProperties (node); + ASSERT_TRUE (properties.has_value()); + properties->name = "Renamed Test Node"; + + EXPECT_TRUE (model->setNodeProperties (node, std::move (*properties))); + EXPECT_FALSE (graph.hasUncommittedChanges()); + EXPECT_TRUE (graph.commitChanges().wasOk()); + EXPECT_FALSE (graph.hasUncommittedChanges()); + + auto* processor = dynamic_cast (model->getNodeProcessor (node)); + ASSERT_NE (nullptr, processor); + + processor->setLatencySamplesForTest (16); + EXPECT_FALSE (graph.hasUncommittedChanges()); + EXPECT_EQ (16, graph.getLatencySamples()); + EXPECT_GT (latencyQueryCount.load(), latencyQueriesAfterCommit); +} + #if ! defined(YUP_WASM) TEST (AudioGraphProcessorTests, CommitKeepsDirtyWhenModelChangesDuringCompilation) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); 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()); + const auto blockingNode = model->addNode (std::make_unique (latencyEntered, allowLatencyQueryToContinue)); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (blockingNode, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (blockingNode, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); std::atomic commitSucceeded { false }; std::thread commitThread ([&] @@ -1076,7 +1163,7 @@ TEST (AudioGraphProcessorTests, CommitKeepsDirtyWhenModelChangesDuringCompilatio return; } - const auto addedDuringCommit = graph.addNode (std::make_unique()); + const auto addedDuringCommit = model->addNode (std::make_unique()); EXPECT_TRUE (addedDuringCommit.isValid()); allowLatencyQueryToContinue.store (true); @@ -1092,10 +1179,11 @@ TEST (AudioGraphProcessorTests, CommitKeepsDirtyWhenModelChangesDuringCompilatio TEST (AudioGraphProcessorTests, SaveAndLoadRestoresConnectionsAndNodeState) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); 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 auto node = model->addNode (std::move (processor), statefulGainProperties (12.0f, 34.0f)); const AudioGraphConnection inputConnection { AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }; @@ -1104,8 +1192,8 @@ TEST (AudioGraphProcessorTests, SaveAndLoadRestoresConnectionsAndNodeState) const AudioGraphConnection bypassConnection { AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }; - ASSERT_TRUE (graph.addConnection (inputConnection).wasOk()); - ASSERT_TRUE (graph.addConnection (outputConnection).wasOk()); + ASSERT_TRUE (model->addConnection (inputConnection).wasOk()); + ASSERT_TRUE (model->addConnection (outputConnection).wasOk()); ASSERT_TRUE (graph.commitChanges().wasOk()); MemoryBlock savedState; @@ -1123,22 +1211,22 @@ TEST (AudioGraphProcessorTests, SaveAndLoadRestoresConnectionsAndNodeState) 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 (model->removeConnection (inputConnection)); + EXPECT_TRUE (model->removeConnection (outputConnection)); + EXPECT_TRUE (model->addConnection (bypassConnection).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); - graph.setNodeFactory (statefulGainFactory()); + model->setNodeFactory (statefulGainFactory()); EXPECT_TRUE (graph.loadStateFromMemory (savedState).wasOk()); EXPECT_FALSE (graph.hasUncommittedChanges()); - const auto properties = graph.getNodeProperties (node); + const auto properties = model->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(); + const auto connections = model->getConnections(); ASSERT_EQ (2u, connections.size()); EXPECT_EQ (inputConnection, connections[0]); EXPECT_EQ (outputConnection, connections[1]); @@ -1155,23 +1243,25 @@ TEST (AudioGraphProcessorTests, SaveAndLoadRestoresConnectionsAndNodeState) TEST (AudioGraphProcessorTests, LoadStateRecreatesProcessorNodesWithFactory) { - AudioGraphProcessor source; - const auto node = source.addNode (std::make_unique (0.25f), - statefulGainProperties (64.0f, 128.0f)); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->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()); + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (statefulGainFactory()); EXPECT_TRUE (destination.loadStateFromMemory (savedState).wasOk()); - const auto properties = destination.getNodeProperties (node); + const auto properties = destinationModel->getNodeProperties (node); ASSERT_TRUE (properties.has_value()); EXPECT_EQ (String ("statefulGain"), properties->identifier); EXPECT_FLOAT_EQ (64.0f, properties->positionX); @@ -1189,25 +1279,27 @@ TEST (AudioGraphProcessorTests, LoadStateRecreatesProcessorNodesWithFactory) TEST (AudioGraphProcessorTests, CreateXmlAndRestoreFromXmlCanBeUsedDirectly) { - AudioGraphProcessor source; - const auto node = source.addNode (std::make_unique (0.25f), - statefulGainProperties (96.0f, 192.0f)); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->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()); + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (statefulGainFactory()); ASSERT_TRUE (destination.restoreFromXml (*xml).wasOk()); EXPECT_FALSE (destination.hasUncommittedChanges()); - const auto properties = destination.getNodeProperties (node); + const auto properties = destinationModel->getNodeProperties (node); ASSERT_TRUE (properties.has_value()); EXPECT_EQ (String ("statefulGain"), properties->identifier); EXPECT_FLOAT_EQ (96.0f, properties->positionX); @@ -1225,29 +1317,32 @@ TEST (AudioGraphProcessorTests, CreateXmlAndRestoreFromXmlCanBeUsedDirectly) TEST (AudioGraphProcessorTests, LoadStateFailsWhenFactoryIsMissing) { - AudioGraphProcessor source; - const auto node = source.addNode (std::make_unique (0.25f), - statefulGainProperties()); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); ASSERT_TRUE (source.commitChanges().wasOk()); MemoryBlock savedState; ASSERT_TRUE (source.saveStateIntoMemory (savedState).wasOk()); - AudioGraphProcessor destination; + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); 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)); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); ASSERT_TRUE (source.commitChanges().wasOk()); MemoryBlock savedState; @@ -1265,14 +1360,15 @@ TEST (AudioGraphProcessorTests, LoadStateRecreatesExternalNodesWithOpaqueCreatio int externalLoadCount = 0; String externalIdentifier; - AudioGraphProcessor destination; - destination.setNodeFactory (statefulGainAndExternalFactory (externalLoadCount, externalIdentifier)); + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->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); + const auto properties = destinationModel->getNodeProperties (node); ASSERT_TRUE (properties.has_value()); EXPECT_EQ (String ("externalPlugin"), properties->identifier); EXPECT_FALSE (properties->creationData.isEmpty()); @@ -1291,22 +1387,24 @@ TEST (AudioGraphProcessorTests, LoadStateRecreatesExternalNodesWithOpaqueCreatio TEST (AudioGraphProcessorTests, LoadStateFailureRestoresPreviousGraphModel) { - AudioGraphProcessor invalidSource; - ASSERT_TRUE (invalidSource.addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID (999), 0), - AudioGraphEndpoint::graphOutput (0) }) + auto invalidSourceModel = std::make_shared(); + AudioGraphProcessor invalidSource (invalidSourceModel); + ASSERT_TRUE (invalidSourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID (999), 0), + AudioGraphEndpoint::graphOutput (0) }) .wasOk()); MemoryBlock invalidState; ASSERT_TRUE (invalidSource.saveStateIntoMemory (invalidState).wasOk()); - AudioGraphProcessor destination; + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); 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(); + const auto connections = destinationModel->getConnections(); ASSERT_EQ (2u, connections.size()); AudioBuffer audio (2, 16); @@ -1321,16 +1419,17 @@ TEST (AudioGraphProcessorTests, LoadStateFailureRestoresPreviousGraphModel) 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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto gainNode = model->addNode (std::make_unique (0.5f), + statefulGainProperties (11.0f, 22.0f)); + const auto externalNode = model->addNode (std::make_unique ("External", 0.25f), + externalProcessorProperties (33.0f, 44.0f)); + + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (gainNode, 0) }).wasOk()); + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (gainNode, 0), AudioGraphEndpoint::nodeInput (externalNode, 0) }).wasOk()); + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (externalNode, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); ASSERT_TRUE (graph.commitChanges().wasOk()); MemoryBlock savedState; @@ -1377,37 +1476,39 @@ TEST (AudioGraphProcessorTests, SaveStateWritesCompleteXmlTopology) 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)); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto firstNode = sourceModel->addNode (std::make_unique (0.5f), + statefulGainProperties (10.0f, 20.0f)); + const auto secondNode = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (firstNode, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (firstNode, 0), AudioGraphEndpoint::nodeInput (secondNode, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->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()); + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (statefulGainFactory()); ASSERT_TRUE (destination.loadStateFromMemory (savedState).wasOk()); EXPECT_FALSE (destination.hasUncommittedChanges()); - const auto firstProperties = destination.getNodeProperties (firstNode); + const auto firstProperties = destinationModel->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); + const auto secondProperties = destinationModel->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(); + const auto connections = destinationModel->getConnections(); ASSERT_EQ (3u, connections.size()); AudioBuffer audio (2, 16); @@ -1419,17 +1520,18 @@ TEST (AudioGraphProcessorTests, LoadStateRestoresMultiNodeXmlGraphAndNextNodeID) 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()); + const auto nextNode = destinationModel->addNode (std::make_unique()); EXPECT_GT (nextNode.getRawID(), secondNode.getRawID()); } TEST (AudioGraphProcessorTests, SaveStateFailsWhenNodeStateSaveFails) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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()); + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); MemoryBlock savedState; EXPECT_TRUE (graph.saveStateIntoMemory (savedState).failed()); @@ -1437,11 +1539,12 @@ TEST (AudioGraphProcessorTests, SaveStateFailsWhenNodeStateSaveFails) TEST (AudioGraphProcessorTests, CreateXmlReturnsNullWhenNodeStateSaveFails) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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()); + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); auto xml = graph.createXml(); EXPECT_EQ (nullptr, xml.get()); @@ -1449,7 +1552,8 @@ TEST (AudioGraphProcessorTests, CreateXmlReturnsNullWhenNodeStateSaveFails) TEST (AudioGraphProcessorTests, LoadStateRejectsMalformedXmlHeaderAndUnsupportedVersion) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); const String unsupportedVersionXml = "" "" "" @@ -1462,7 +1566,8 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsMalformedXmlHeaderAndUnsupported TEST (AudioGraphProcessorTests, LoadStateRejectsMissingRequiredXmlSections) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); const String missingNodesXml = "" "" ""; @@ -1476,12 +1581,13 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsMissingRequiredXmlSections) TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidBase64Payloads) { - AudioGraphProcessor source; - const auto node = source.addNode (std::make_unique ("External", 0.25f), - externalProcessorProperties()); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); ASSERT_TRUE (source.commitChanges().wasOk()); MemoryBlock savedState; @@ -1497,8 +1603,9 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidBase64Payloads) int externalLoadCount = 0; String externalIdentifier; - AudioGraphProcessor destination; - destination.setNodeFactory (statefulGainAndExternalFactory (externalLoadCount, externalIdentifier)); + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (statefulGainAndExternalFactory (externalLoadCount, externalIdentifier)); EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*invalidNodeState)).failed()); EXPECT_EQ (0, externalLoadCount); @@ -1515,12 +1622,13 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidBase64Payloads) TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidConnectionXml) { - AudioGraphProcessor source; - const auto node = source.addNode (std::make_unique (0.5f), - statefulGainProperties()); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); ASSERT_TRUE (source.commitChanges().wasOk()); MemoryBlock savedState; @@ -1532,8 +1640,9 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidConnectionXml) ASSERT_NE (nullptr, firstConnection); firstConnection->getChildByName ("source")->setAttribute ("kind", "invalidKind"); - AudioGraphProcessor destination; - destination.setNodeFactory (statefulGainFactory()); + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (statefulGainFactory()); EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*invalidKind)).failed()); auto missingDestination = parseMemoryBlockAsXml (savedState); @@ -1547,19 +1656,21 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidConnectionXml) TEST (AudioGraphProcessorTests, LoadStateFailsWhenFactoryReturnsNullProcessor) { - AudioGraphProcessor source; - const auto node = source.addNode (std::make_unique (0.25f), - statefulGainProperties()); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->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> + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory ([] (const AudioGraphNodeProperties&) -> ResultValue> { return makeResultValueOk (std::unique_ptr()); }); @@ -1569,12 +1680,13 @@ TEST (AudioGraphProcessorTests, LoadStateFailsWhenFactoryReturnsNullProcessor) TEST (AudioGraphProcessorTests, LoadStateFailureFromNodeStateCallbackRestoresPreviousGraph) { - AudioGraphProcessor source; - const auto node = source.addNode (std::make_unique (0.25f), - statefulGainProperties()); + auto sourceModel = std::make_shared(); + AudioGraphProcessor source (sourceModel); + const auto node = sourceModel->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 (sourceModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + ASSERT_TRUE (sourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); ASSERT_TRUE (source.commitChanges().wasOk()); MemoryBlock savedState; @@ -1589,8 +1701,9 @@ TEST (AudioGraphProcessorTests, LoadStateFailureFromNodeStateCallbackRestoresPre const MemoryBlock invalidState ("bad", 3); stateElement->addTextElement (toBase64Text (invalidState)); - AudioGraphProcessor destination; - destination.setNodeFactory (statefulGainFactory()); + auto destinationModel = std::make_shared(); + AudioGraphProcessor destination (destinationModel); + destinationModel->setNodeFactory (statefulGainFactory()); ASSERT_TRUE (resetGraphToSingleGainPath (destination, 0.5f)); EXPECT_FALSE (destination.hasUncommittedChanges()); @@ -1609,17 +1722,18 @@ TEST (AudioGraphProcessorTests, LoadStateFailureFromNodeStateCallbackRestoresPre TEST (AudioGraphProcessorTests, RemoveConnectionStopsRoutingAfterCommit) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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 (model->addConnection (inputConnection).wasOk()); + EXPECT_TRUE (model->addConnection (outputConnection).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); - EXPECT_TRUE (graph.removeConnection (outputConnection)); + EXPECT_TRUE (model->removeConnection (outputConnection)); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -1629,18 +1743,19 @@ TEST (AudioGraphProcessorTests, RemoveConnectionStopsRoutingAfterCommit) graph.processBlock (audio, midi); EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); - EXPECT_FALSE (graph.removeConnection (outputConnection)); + EXPECT_FALSE (model->removeConnection (outputConnection)); } TEST (AudioGraphProcessorTests, RemoveNodePrunesConnections) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->removeNode (node)); + EXPECT_EQ (nullptr, model->getNodeProcessor (node)); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -1650,19 +1765,20 @@ TEST (AudioGraphProcessorTests, RemoveNodePrunesConnections) graph.processBlock (audio, midi); EXPECT_FLOAT_EQ (0.0f, audio.getReadPointer (0)[0]); - EXPECT_FALSE (graph.removeNode (node)); + EXPECT_FALSE (model->removeNode (node)); } TEST (AudioGraphProcessorTests, ClearRemovesAllRouting) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); - graph.clear(); + model->clear(); EXPECT_TRUE (graph.hasUncommittedChanges()); EXPECT_TRUE (graph.commitChanges().wasOk()); EXPECT_FALSE (graph.hasUncommittedChanges()); @@ -1678,10 +1794,11 @@ TEST (AudioGraphProcessorTests, ClearRemovesAllRouting) TEST (AudioGraphProcessorTests, ReleaseResourcesMarksPreparedNodesUnprepared) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); auto processor = std::make_unique(); auto* processorPtr = processor.get(); - const auto node = graph.addNode (std::move (processor)); + const auto node = model->addNode (std::move (processor)); EXPECT_TRUE (node.isValid()); graph.prepareToPlay (44100.0f, 64); @@ -1694,14 +1811,15 @@ TEST (AudioGraphProcessorTests, ReleaseResourcesMarksPreparedNodesUnprepared) TEST (AudioGraphProcessorTests, ReportsAllocationStats) { - AudioGraphProcessor graph; - const auto first = graph.addNode (std::make_unique()); - const auto second = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto first = model->addNode (std::make_unique()); + const auto second = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); const auto stats = graph.getAllocationStats(); @@ -1714,25 +1832,27 @@ TEST (AudioGraphProcessorTests, ReportsAllocationStats) 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)); + auto singleThreadedModel = std::make_shared(); + AudioGraphProcessor singleThreaded (singleThreadedModel); + const auto singleLeft = singleThreadedModel->addNode (std::make_unique (0.25f)); + const auto singleRight = singleThreadedModel->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 (singleThreadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (singleLeft, 0) }).wasOk()); + EXPECT_TRUE (singleThreadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (singleRight, 0) }).wasOk()); + EXPECT_TRUE (singleThreadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (singleLeft, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (singleThreadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (singleRight, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (singleThreaded.commitChanges().wasOk()); - AudioGraphProcessor threaded; + auto threadedModel = std::make_shared(); + AudioGraphProcessor threaded (threadedModel); threaded.setNumWorkerThreads (2); - const auto threadedLeft = threaded.addNode (std::make_unique (0.25f)); - const auto threadedRight = threaded.addNode (std::make_unique (0.75f)); + const auto threadedLeft = threadedModel->addNode (std::make_unique (0.25f)); + const auto threadedRight = threadedModel->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 (threadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (threadedLeft, 0) }).wasOk()); + EXPECT_TRUE (threadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (threadedRight, 0) }).wasOk()); + EXPECT_TRUE (threadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (threadedLeft, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (threadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (threadedRight, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (threaded.commitChanges().wasOk()); AudioBuffer singleAudio (2, 16); @@ -1761,29 +1881,31 @@ TEST (AudioGraphProcessorTests, WorkerThreadsMatchSingleThreadOutputUnderLoad) constexpr int numSamples = 64; constexpr int numBlocks = 128; - AudioGraphProcessor singleThreaded; - AudioGraphProcessor threaded; + auto singleThreadedModel = std::make_shared(); + AudioGraphProcessor singleThreaded (singleThreadedModel); + auto threadedModel = std::make_shared(); + AudioGraphProcessor threaded (threadedModel); 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)); + const auto singleNode = singleThreadedModel->addNode (std::make_unique (gain)); + const auto threadedNode = threadedModel->addNode (std::make_unique (gain)); EXPECT_TRUE (singleNode.isValid()); EXPECT_TRUE (threadedNode.isValid()); - EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::graphInput (0), - AudioGraphEndpoint::nodeInput (singleNode, 0) }) + EXPECT_TRUE (singleThreadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (singleNode, 0) }) .wasOk()); - EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::nodeOutput (singleNode, 0), - AudioGraphEndpoint::graphOutput (0) }) + EXPECT_TRUE (singleThreadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (singleNode, 0), + AudioGraphEndpoint::graphOutput (0) }) .wasOk()); - EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::graphInput (0), - AudioGraphEndpoint::nodeInput (threadedNode, 0) }) + EXPECT_TRUE (threadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (threadedNode, 0) }) .wasOk()); - EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::nodeOutput (threadedNode, 0), - AudioGraphEndpoint::graphOutput (0) }) + EXPECT_TRUE (threadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (threadedNode, 0), + AudioGraphEndpoint::graphOutput (0) }) .wasOk()); } @@ -1834,28 +1956,30 @@ TEST (AudioGraphProcessorTests, SwitchingWorkerThreadsBetweenBlocksPreservesOutp constexpr int threadCounts[] = { 0, 1, 4, 2, 0, 3 }; constexpr int numThreadCounts = sizeof (threadCounts) / sizeof (threadCounts[0]); - AudioGraphProcessor singleThreaded; - AudioGraphProcessor threaded; + auto singleThreadedModel = std::make_shared(); + AudioGraphProcessor singleThreaded (singleThreadedModel); + auto threadedModel = std::make_shared(); + AudioGraphProcessor threaded (threadedModel); 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)); + const auto singleNode = singleThreadedModel->addNode (std::make_unique (gain)); + const auto threadedNode = threadedModel->addNode (std::make_unique (gain)); EXPECT_TRUE (singleNode.isValid()); EXPECT_TRUE (threadedNode.isValid()); - EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::graphInput (0), - AudioGraphEndpoint::nodeInput (singleNode, 0) }) + EXPECT_TRUE (singleThreadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (singleNode, 0) }) .wasOk()); - EXPECT_TRUE (singleThreaded.addConnection ({ AudioGraphEndpoint::nodeOutput (singleNode, 0), - AudioGraphEndpoint::graphOutput (0) }) + EXPECT_TRUE (singleThreadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (singleNode, 0), + AudioGraphEndpoint::graphOutput (0) }) .wasOk()); - EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::graphInput (0), - AudioGraphEndpoint::nodeInput (threadedNode, 0) }) + EXPECT_TRUE (threadedModel->addConnection ({ AudioGraphEndpoint::graphInput (0), + AudioGraphEndpoint::nodeInput (threadedNode, 0) }) .wasOk()); - EXPECT_TRUE (threaded.addConnection ({ AudioGraphEndpoint::nodeOutput (threadedNode, 0), - AudioGraphEndpoint::graphOutput (0) }) + EXPECT_TRUE (threadedModel->addConnection ({ AudioGraphEndpoint::nodeOutput (threadedNode, 0), + AudioGraphEndpoint::graphOutput (0) }) .wasOk()); } @@ -1903,7 +2027,8 @@ TEST (AudioGraphProcessorTests, SwitchingWorkerThreadsBetweenBlocksPreservesOutp #if ! defined(YUP_WASM) TEST (AudioGraphProcessorTests, ConcurrentCommitsDoNotInvalidateAudioThreadPlan) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); graph.setNumWorkerThreads (2); graph.prepareToPlay (48000.0f, 32); @@ -1983,12 +2108,13 @@ TEST (AudioGraphProcessorTests, ConcurrentCommitsDoNotInvalidateAudioThreadPlan) TEST (AudioGraphProcessorTests, MidiCompensationCanSpillIntoNextBlock) { AudioBusLayout layout = midiLayout(); - AudioGraphProcessor graph (layout); - const auto latent = graph.addNode (std::make_unique (6)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, layout); + const auto latent = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latent, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (latent, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (0, 8); @@ -2009,16 +2135,17 @@ TEST (AudioGraphProcessorTests, MidiCompensationCanSpillIntoNextBlock) 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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto dry = model->addNode (std::make_unique()); + const auto firstDelay = model->addNode (std::make_unique (3)); + const auto secondDelay = model->addNode (std::make_unique (4)); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (firstDelay, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (firstDelay, 0), AudioGraphEndpoint::nodeInput (secondDelay, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (secondDelay, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2036,16 +2163,17 @@ TEST (AudioGraphProcessorTests, PdcAccumulatesSerialPathLatencyAtGraphOutput) 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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto dry = model->addNode (std::make_unique()); + const auto delayed = model->addNode (std::make_unique (5)); + const auto summer = model->addNode (std::make_unique()); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::nodeInput (summer, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::nodeInput (summer, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (summer, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2062,12 +2190,13 @@ TEST (AudioGraphProcessorTests, PdcCompensatesFanInAtProcessorInput) TEST (AudioGraphProcessorTests, PdcDelaysDirectAudioOutputToMatchLatentAudioPath) { - AudioGraphProcessor graph; - const auto delayed = graph.addNode (std::make_unique (5)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto delayed = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2084,14 +2213,15 @@ TEST (AudioGraphProcessorTests, PdcDelaysDirectAudioOutputToMatchLatentAudioPath TEST (AudioGraphProcessorTests, PdcAudioDelayLongerThanBlockSpillsAcrossBlocks) { - AudioGraphProcessor graph; - const auto dry = graph.addNode (std::make_unique()); - const auto delayed = graph.addNode (std::make_unique (10)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto dry = model->addNode (std::make_unique()); + const auto delayed = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 4); @@ -2114,12 +2244,13 @@ TEST (AudioGraphProcessorTests, PdcAudioDelayLongerThanBlockSpillsAcrossBlocks) TEST (AudioGraphProcessorTests, PdcDirectAudioOutputCompensationSpillsAcrossBlocks) { - AudioGraphProcessor graph; - const auto delayed = graph.addNode (std::make_unique (6)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto delayed = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 4); @@ -2137,12 +2268,13 @@ TEST (AudioGraphProcessorTests, PdcDirectAudioOutputCompensationSpillsAcrossBloc TEST (AudioGraphProcessorTests, PdcFlushClearsPendingAudioCompensation) { - AudioGraphProcessor graph; - const auto latencyReporter = graph.addNode (std::make_unique (0.0f, 6)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto latencyReporter = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latencyReporter, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (latencyReporter, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 4); @@ -2163,12 +2295,13 @@ TEST (AudioGraphProcessorTests, PdcFlushClearsPendingAudioCompensation) TEST (AudioGraphProcessorTests, PdcMidiDelayLongerThanOneBlockAlignsWithDelayedProcessor) { - AudioGraphProcessor graph (midiLayout()); - const auto delayed = graph.addNode (std::make_unique (18)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, midiLayout()); + const auto delayed = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (delayed, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (0, 8); @@ -2195,12 +2328,13 @@ TEST (AudioGraphProcessorTests, PdcMidiDelayLongerThanOneBlockAlignsWithDelayedP TEST (AudioGraphProcessorTests, PdcFlushClearsPendingMidiCompensation) { - AudioGraphProcessor graph (midiLayout()); - const auto latencyReporter = graph.addNode (std::make_unique (10)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, midiLayout()); + const auto latencyReporter = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latencyReporter, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (latencyReporter, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (0, 8); @@ -2224,17 +2358,18 @@ TEST (AudioGraphProcessorTests, PdcFlushClearsPendingMidiCompensation) TEST (AudioGraphProcessorTests, PdcRecompileAfterRemovingLatencyPathClearsCompensation) { - AudioGraphProcessor graph; - const auto delayed = graph.addNode (std::make_unique (0.0f, 12)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto delayed = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (delayed, 0) }).wasOk()); + EXPECT_TRUE (model->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 (model->removeNode (delayed)); EXPECT_TRUE (graph.commitChanges().wasOk()); EXPECT_EQ (0, graph.getLatencySamples()); EXPECT_EQ (0, graph.getAllocationStats().delayLines); @@ -2250,16 +2385,17 @@ TEST (AudioGraphProcessorTests, PdcRecompileAfterRemovingLatencyPathClearsCompen TEST (AudioGraphProcessorTests, WorkerThreadsProcessManyBlocksCorrectly) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); graph.setNumWorkerThreads (2); - const auto left = graph.addNode (std::make_unique (0.5f)); - const auto right = graph.addNode (std::make_unique (0.5f)); + const auto left = model->addNode (std::make_unique (0.5f)); + const auto right = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (left, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (right, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (left, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (right, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2275,11 +2411,12 @@ TEST (AudioGraphProcessorTests, WorkerThreadsProcessManyBlocksCorrectly) TEST (AudioGraphProcessorTests, WorkerThreadCountCanBeChangedAfterProcessing) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); - 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()); + const auto node = model->addNode (std::make_unique (0.5f)); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2305,12 +2442,13 @@ TEST (AudioGraphProcessorTests, WorkerThreadCountCanBeChangedAfterProcessing) TEST (AudioGraphProcessorTests, WorkerThreadsContinueProcessingAfterIdleReset) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); 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()); + const auto node = model->addNode (std::make_unique (0.5f)); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2332,12 +2470,13 @@ TEST (AudioGraphProcessorTests, WorkerThreadsContinueProcessingAfterIdleReset) TEST (AudioGraphProcessorTests, ZeroWorkerThreadsProcessesCorrectly) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); 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()); + const auto node = model->addNode (std::make_unique (0.5f)); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2355,16 +2494,17 @@ TEST (AudioGraphProcessorTests, WorkerThreadOutputMatchesSingleThreadOutputManyB { auto runGraph = [] (int numWorkers) -> std::vector { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); graph.setNumWorkerThreads (numWorkers); - const auto left = graph.addNode (std::make_unique (0.25f)); - const auto right = graph.addNode (std::make_unique (0.25f)); + const auto left = model->addNode (std::make_unique (0.25f)); + const auto right = model->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) }); + model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (left, 0) }); + model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (right, 0) }); + model->addConnection ({ AudioGraphEndpoint::nodeOutput (left, 0), AudioGraphEndpoint::graphOutput (0) }); + model->addConnection ({ AudioGraphEndpoint::nodeOutput (right, 0), AudioGraphEndpoint::graphOutput (0) }); graph.commitChanges(); AudioBuffer audio (2, 16); @@ -2391,11 +2531,12 @@ TEST (AudioGraphProcessorTests, WorkerThreadOutputMatchesSingleThreadOutputManyB TEST (AudioGraphProcessorTests, RapidWorkerThreadResizeDoesNotCrash) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); - 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()); + const auto node = model->addNode (std::make_unique (1.0f)); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2415,14 +2556,15 @@ TEST (AudioGraphProcessorTests, RapidWorkerThreadResizeDoesNotCrash) #if ! defined(YUP_WASM) TEST (AudioGraphProcessorTests, ProcessBlockDisablesDenormals) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); auto proc = std::make_unique(); auto* procPtr = proc.get(); - const auto node = graph.addNode (std::move (proc)); + const auto node = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2438,11 +2580,12 @@ TEST (AudioGraphProcessorTests, ProcessBlockDisablesDenormals) TEST (AudioGraphProcessorTests, ManyMidiEventsPassThroughGraphCorrectly) { AudioBusLayout layout = midiLayout(); - AudioGraphProcessor graph (layout); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, 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()); + const auto node = model->addNode (std::make_unique()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (0, 128); @@ -2465,16 +2608,17 @@ 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; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); graph.setNumWorkerThreads (2); - const auto dry = graph.addNode (std::make_unique (1.0f, 0)); - const auto latent = graph.addNode (std::make_unique (4)); + const auto dry = model->addNode (std::make_unique (1.0f, 0)); + const auto latent = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latent, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (latent, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2496,18 +2640,19 @@ TEST (AudioGraphProcessorTests, WorkerThreadsPdcMatchesSingleThreadOutput) // blocks including the initial PDC fill period. auto buildGraph = [] (int numWorkers) -> std::unique_ptr { - auto g = std::make_unique(); + auto model = std::make_shared(); + auto g = std::make_unique (model); 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)); + const auto dry = model->addNode (std::make_unique (1.0f, 0)); + const auto firstDelay = model->addNode (std::make_unique (3)); + const auto secondDelay = model->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) }); + model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (dry, 0) }); + model->addConnection ({ AudioGraphEndpoint::nodeOutput (dry, 0), AudioGraphEndpoint::graphOutput (0) }); + model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (firstDelay, 0) }); + model->addConnection ({ AudioGraphEndpoint::nodeOutput (firstDelay, 0), AudioGraphEndpoint::nodeInput (secondDelay, 0) }); + model->addConnection ({ AudioGraphEndpoint::nodeOutput (secondDelay, 0), AudioGraphEndpoint::graphOutput (0) }); g->commitChanges(); return g; @@ -2548,13 +2693,14 @@ 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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, mixedLayout()); + const auto node = model->addNode (std::make_unique (0.5f)); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::nodeInput (node, 1) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2575,16 +2721,17 @@ 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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, mixedLayout()); + const auto first = model->addNode (std::make_unique (0.5f)); + const auto second = model->addNode (std::make_unique (0.5f)); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (first, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::nodeInput (first, 1) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (first, 0), AudioGraphEndpoint::nodeInput (second, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (first, 1), AudioGraphEndpoint::nodeInput (second, 1) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (second, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (second, 1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioBuffer audio (2, 16); @@ -2615,15 +2762,16 @@ TEST (AudioGraphProcessorTests, MixedAudioMidiProcessorPdcCompensatesDirectPaths // 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()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model, mixedLayout()); + const auto latentNode = model->addNode (std::make_unique (5)); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (latentNode, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (latentNode, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (1), AudioGraphEndpoint::nodeInput (latentNode, 1) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (latentNode, 1), AudioGraphEndpoint::graphOutput (1) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); EXPECT_EQ (5, graph.getLatencySamples()); @@ -2646,44 +2794,46 @@ TEST (AudioGraphProcessorTests, MixedAudioMidiProcessorPdcCompensatesDirectPaths TEST (AudioGraphProcessorTests, GetNodeIDsReturnsAllAddedNodes) { - AudioGraphProcessor graph; + auto model = std::make_shared(); + AudioGraphProcessor graph (model); - EXPECT_TRUE (graph.getNodeIDs().empty()); + EXPECT_TRUE (model->getNodeIDs().empty()); - const auto idA = graph.addNode (std::make_unique()); - const auto idB = graph.addNode (std::make_unique()); + const auto idA = model->addNode (std::make_unique()); + const auto idB = model->addNode (std::make_unique()); - const auto ids = graph.getNodeIDs(); + const auto ids = model->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_TRUE (model->removeNode (idA)); + const auto idsAfter = model->getNodeIDs(); EXPECT_EQ (1u, idsAfter.size()); EXPECT_EQ (idsAfter.end(), std::find (idsAfter.begin(), idsAfter.end(), idA)); } TEST (AudioGraphProcessorTests, ReplaceNodeProcessorPreservesNodeIDAndCompatibleConnections) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique (1.0f)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioGraphNodeProperties props; props.identifier = "replacement"; props.name = "Replacement"; - EXPECT_TRUE (graph.replaceNodeProcessor (node, std::make_unique (0.25f), props).wasOk()); + EXPECT_TRUE (model->replaceNode (node, std::make_unique (0.25f), props).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); - const auto ids = graph.getNodeIDs(); + const auto ids = model->getNodeIDs(); EXPECT_EQ (1u, ids.size()); EXPECT_EQ (node, ids.front()); - EXPECT_EQ (2u, graph.getConnections().size()); + EXPECT_EQ (2u, model->getConnections().size()); AudioBuffer audio (2, 16); MidiBuffer midi; @@ -2697,35 +2847,37 @@ TEST (AudioGraphProcessorTests, ReplaceNodeProcessorPreservesNodeIDAndCompatible TEST (AudioGraphProcessorTests, ReplaceNodeProcessorPrunesIncompatibleConnections) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique (1.0f)); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->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 (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 0) }).wasOk()); + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (node, 0), AudioGraphEndpoint::graphOutput (0) }).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); AudioGraphNodeProperties props; props.identifier = "mono"; props.name = "Mono"; - EXPECT_TRUE (graph.replaceNodeProcessor (node, std::make_unique(), props).wasOk()); + EXPECT_TRUE (model->replaceNode (node, std::make_unique(), props).wasOk()); EXPECT_TRUE (graph.commitChanges().wasOk()); - const auto ids = graph.getNodeIDs(); + const auto ids = model->getNodeIDs(); EXPECT_EQ (1u, ids.size()); EXPECT_EQ (node, ids.front()); - EXPECT_TRUE (graph.getConnections().empty()); + EXPECT_TRUE (model->getConnections().empty()); } TEST (AudioGraphProcessorTests, ReplaceNodeProcessorRejectsInvalidRequests) { - AudioGraphProcessor graph; - const auto node = graph.addNode (std::make_unique()); + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->addNode (std::make_unique()); AudioGraphNodeProperties props; props.identifier = "replacement"; - EXPECT_TRUE (graph.replaceNodeProcessor (AudioGraphNodeID::invalid(), std::make_unique(), props).failed()); - EXPECT_TRUE (graph.replaceNodeProcessor (AudioGraphNodeID (999), std::make_unique(), props).failed()); - EXPECT_TRUE (graph.replaceNodeProcessor (node, nullptr, props).failed()); + EXPECT_TRUE (model->replaceNode (AudioGraphNodeID::invalid(), std::make_unique(), props).failed()); + EXPECT_TRUE (model->replaceNode (AudioGraphNodeID (999), std::make_unique(), props).failed()); + EXPECT_TRUE (model->replaceNode (node, nullptr, props).failed()); } diff --git a/tests/yup_audio_gui/yup_AudioGraphComponent.cpp b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp index ea9147c95..124df5593 100644 --- a/tests/yup_audio_gui/yup_AudioGraphComponent.cpp +++ b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp @@ -183,20 +183,45 @@ class AudioGraphComponentTests : public ::testing::Test protected: void SetUp() override { - graph = std::make_shared(); + model = std::make_shared(); + graph = std::make_shared (model); component = std::make_unique (graph); component->setBounds (0.0f, 0.0f, 800.0f, 600.0f); + component->onConnectionRequested = [this] (const AudioGraphConnection& connection) + { + return model->addConnection (connection).wasOk() && graph->commitChanges().wasOk(); + }; + component->onConnectionRemovalRequested = [this] (const AudioGraphConnection& connection) + { + return model->removeConnection (connection) && graph->commitChanges().wasOk(); + }; + component->onEndpointConnectionsRemovalRequested = [this] (const AudioGraphEndpoint& endpoint) + { + auto connections = model->getConnections(); + bool removedAny = false; + + for (const auto& connection : connections) + if (connection.source == endpoint || connection.destination == endpoint) + removedAny = model->removeConnection (connection) || removedAny; + + return removedAny && graph->commitChanges().wasOk(); + }; + component->onNodeMoveRequested = [this] (AudioGraphNodeID nodeID, Point, Point newCanvasPos) + { + return model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); + }; } void TearDown() override { component.reset(); + model.reset(); graph.reset(); } AudioGraphNodeID addProcessorNode() { - return graph->addNode (std::make_unique()); + return model->addNode (std::make_unique()); } TestNodeView* addNodeView (AudioGraphNodeID nodeID, @@ -223,6 +248,7 @@ class AudioGraphComponentTests : public ::testing::Test } std::shared_ptr graph; + std::shared_ptr model; std::unique_ptr component; }; @@ -674,7 +700,7 @@ TEST_F (AudioGraphComponentTests, ClickingCompatibleEndpointsAddsConnectionAndNo component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (source))); component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (destination))); - const auto connections = graph->getConnections(); + const auto connections = model->getConnections(); ASSERT_EQ (1u, connections.size()); EXPECT_EQ (AudioGraphConnection (source, destination), connections.front()); @@ -701,7 +727,7 @@ TEST_F (AudioGraphComponentTests, DraggingCompatibleEndpointsAddsConnectionAndCl component->mouseDrag (mouseEventForComponent (MouseEvent::leftButton, sourcePosition + Point (20.0f, 0.0f))); component->mouseUp (mouseEventForComponent (MouseEvent::leftButton, destinationPosition)); - const auto connections = graph->getConnections(); + const auto connections = model->getConnections(); ASSERT_EQ (1u, connections.size()); EXPECT_EQ (AudioGraphConnection (source, destination), connections.front()); EXPECT_FALSE (component->isPendingWireVisible()); @@ -721,7 +747,7 @@ TEST_F (AudioGraphComponentTests, IncompatibleEndpointPairKeepsNewEndpointArmedA component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (firstInput))); component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (secondInput))); - EXPECT_TRUE (graph->getConnections().empty()); + EXPECT_TRUE (model->getConnections().empty()); ASSERT_TRUE (component->getPendingWireEndpoint().has_value()); EXPECT_EQ (secondInput, *component->getPendingWireEndpoint()); } @@ -736,7 +762,7 @@ TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesAllConnectionsForEndp const AudioGraphConnection connection { AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (nodeID, 0) }; - ASSERT_TRUE (graph->addConnection (connection).wasOk()); + ASSERT_TRUE (model->addConnection (connection).wasOk()); ASSERT_TRUE (graph->commitChanges().wasOk()); RecordingGraphListener listener; @@ -744,7 +770,7 @@ TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesAllConnectionsForEndp component->mouseDown (mouseEventForComponent (MouseEvent::rightButton, component->getEndpointScreenPosition (connection.source))); - EXPECT_TRUE (graph->getConnections().empty()); + EXPECT_TRUE (model->getConnections().empty()); ASSERT_EQ (1u, listener.removedConnections.size()); EXPECT_EQ (connection, listener.removedConnections.front()); @@ -766,8 +792,8 @@ TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesMultipleConnectionsFo const AudioGraphConnection secondConnection { AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (secondNodeID, 0) }; - ASSERT_TRUE (graph->addConnection (firstConnection).wasOk()); - ASSERT_TRUE (graph->addConnection (secondConnection).wasOk()); + ASSERT_TRUE (model->addConnection (firstConnection).wasOk()); + ASSERT_TRUE (model->addConnection (secondConnection).wasOk()); ASSERT_TRUE (graph->commitChanges().wasOk()); RecordingGraphListener listener; @@ -775,7 +801,7 @@ TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesMultipleConnectionsFo component->mouseDown (mouseEventForComponent (MouseEvent::rightButton, component->getEndpointScreenPosition (firstConnection.source))); - EXPECT_TRUE (graph->getConnections().empty()); + EXPECT_TRUE (model->getConnections().empty()); ASSERT_EQ (2u, listener.removedConnections.size()); EXPECT_EQ (firstConnection, listener.removedConnections[0]); EXPECT_EQ (secondConnection, listener.removedConnections[1]); @@ -793,7 +819,7 @@ TEST_F (AudioGraphComponentTests, RightClickConnectionRemovesSingleConnection) const AudioGraphConnection connection { AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (nodeID, 0) }; - ASSERT_TRUE (graph->addConnection (connection).wasOk()); + ASSERT_TRUE (model->addConnection (connection).wasOk()); ASSERT_TRUE (graph->commitChanges().wasOk()); const auto midpoint = (component->getEndpointScreenPosition (connection.source) @@ -803,7 +829,7 @@ TEST_F (AudioGraphComponentTests, RightClickConnectionRemovesSingleConnection) component->mouseDown (mouseEventForComponent (MouseEvent::rightButton, midpoint)); - EXPECT_TRUE (graph->getConnections().empty()); + EXPECT_TRUE (model->getConnections().empty()); } TEST_F (AudioGraphComponentTests, DraggingNodeUpdatesCanvasPositionAndNotifiesListener) From 186bb63293e2ebd287085778558d2b093ad5c38a Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 20 May 2026 23:01:23 +0200 Subject: [PATCH 05/14] Fixes to the graph model --- .../graph/yup_AudioGraphModel.cpp | 68 +++++++++-- .../graph/yup_AudioGraphModel.h | 1 + .../graph/yup_AudioGraphProcessor.cpp | 111 +++++++++++++----- .../graph/yup_AudioGraphProcessor.h | 13 +- .../graph/yup_AudioGraphComponent.cpp | 7 ++ .../processors/yup_AudioProcessor.cpp | 20 +++- .../processors/yup_AudioProcessor.h | 5 +- .../yup_AudioGraphProcessor.cpp | 79 +++++++++++-- .../yup_audio_gui/yup_AudioGraphComponent.cpp | 56 +++++++++ 9 files changed, 298 insertions(+), 62 deletions(-) diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp index 4669039fa..f23c2bf9b 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp @@ -350,16 +350,10 @@ Result AudioGraphModel::replaceNode (AudioGraphNodeID nodeID, Result AudioGraphModel::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 (mutex); - if (std::find (connections.begin(), connections.end(), connection) != connections.end()) - return Result::fail ("Audio graph connection already exists"); + if (const auto result = validateConnectionLocked (connection); result.failed()) + return result; connections.push_back (connection); markTopologyChanged(); @@ -708,6 +702,64 @@ ResultValue> AudioGraphModel::createProcessorFor return factoryCopy (properties); } +Result AudioGraphModel::validateConnectionLocked (const AudioGraphConnection& connection) const +{ + 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"); + + if (std::find (connections.begin(), connections.end(), connection) != connections.end()) + return Result::fail ("Audio graph connection already exists"); + + const auto describeEndpoint = [this] (const AudioGraphEndpoint& endpoint) -> ResultValue> + { + if (endpoint.getKind() == AudioGraphEndpoint::Kind::graphInput + || endpoint.getKind() == AudioGraphEndpoint::Kind::graphOutput) + { + return makeResultValueOk (std::optional()); + } + + const auto nodeIterator = std::find_if (nodes.begin(), nodes.end(), [nodeID = endpoint.getNodeID()] (const ModelNode& node) + { + return node.id == nodeID; + }); + + if (nodeIterator == nodes.end() || nodeIterator->processor == nullptr) + return makeResultValueFail ("Audio graph connection references a missing node"); + + const auto descriptor = modelDescribeNodeEndpoint (*nodeIterator->processor, endpoint); + + if (! descriptor.valid) + return makeResultValueFail ("Audio graph connection references an invalid bus index"); + + return makeResultValueOk (std::optional (descriptor)); + }; + + auto source = describeEndpoint (connection.source); + if (! source) + return Result::fail (source.getErrorMessage()); + + auto destination = describeEndpoint (connection.destination); + if (! destination) + return Result::fail (destination.getErrorMessage()); + + const auto sourceDescriptor = std::move (source).getValue(); + const auto destinationDescriptor = std::move (destination).getValue(); + + if (sourceDescriptor.has_value() && destinationDescriptor.has_value()) + { + if (sourceDescriptor->type != destinationDescriptor->type) + return Result::fail ("Audio graph connection mixes audio and MIDI endpoints"); + + if (sourceDescriptor->type == ModelSignalType::audio && sourceDescriptor->channels != destinationDescriptor->channels) + return Result::fail ("Audio graph connection has incompatible channel counts"); + } + + return Result::ok(); +} + void AudioGraphModel::markTopologyChanged() { ++revision; diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.h b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h index 0ae31ce78..c45f2c8fd 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.h +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h @@ -176,6 +176,7 @@ class YUP_API AudioGraphModel final std::vector savedConnections); ResultValue> createProcessorForSavedNode (const AudioGraphNodeProperties& properties); + Result validateConnectionLocked (const AudioGraphConnection& connection) const; void markTopologyChanged(); void markMetadataChanged(); diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp index e70bc20c0..1c3554bfa 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp @@ -30,13 +30,6 @@ enum class GraphSignalType midi }; -struct BusDescriptor -{ - GraphSignalType type = GraphSignalType::audio; - int channels = 0; - int offset = 0; -}; - int getTotalAudioChannels (Span buses) { int result = 0; @@ -259,6 +252,7 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener nodesSnapshot.push_back ({ node.id, node.processor, node.properties }); connectionsSnapshot = snapshot.connections; + std::vector> newlyPreparedNodes; for (auto& node : nodesSnapshot) { @@ -276,6 +270,7 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener { node.processor->setPlayHead (owner.getPlayHead()); node.processor->setPlaybackConfiguration (sampleRate, maxBlockSize); + newlyPreparedNodes.push_back (node.processor); } } @@ -283,22 +278,77 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener const auto result = compileGraph (*compiled, nodesSnapshot, connectionsSnapshot); if (! result) + { + for (auto& processor : newlyPreparedNodes) + if (processor != nullptr) + processor->releaseResources(); + return result; + } for (const auto& node : nodesSnapshot) preparedNodes[node.id.getRawID()] = { node.processor, sampleRate, maxBlockSize, true }; storeStats (compiled->stats); - latestLatencySamples.store (compiled->graphLatencySamples); + const auto newLatencySamples = compiled->graphLatencySamples; + const auto oldLatencySamples = latestLatencySamples.exchange (newLatencySamples); lastCommittedTopologyRevision.store (snapshotTopologyRevision); lastCommittedLatencyChangeCounter.store (snapshotLatencyChangeCounter); lastCompiledSampleRate = sampleRate; lastCompiledMaxBlockSize = maxBlockSize; synchronizeNodeListeners (nodesSnapshot); - delete pendingPlan.exchange (compiled.release()); // TODO - make it so we don't use delete + delete pendingPlan.exchange (compiled.release()); + hasPublishedPlan.store (true); deleteRetiredPlans(); + if (oldLatencySamples != newLatencySamples) + owner.updateHostDisplay (AudioProcessor::ChangeDetails().withLatencyChanged (true)); + + return Result::ok(); + } + + Result validateConnection (const AudioGraphConnection& connection) const + { + const auto snapshot = model->createSnapshot(); + + if (std::find (snapshot.connections.begin(), snapshot.connections.end(), connection) != snapshot.connections.end()) + return Result::fail ("Audio graph connection already exists"); + + std::vector nodesSnapshot; + nodesSnapshot.reserve (snapshot.nodes.size()); + + std::unordered_map nodeIndexByID; + + for (int i = 0; i < static_cast (snapshot.nodes.size()); ++i) + { + const auto& node = snapshot.nodes[static_cast (i)]; + nodesSnapshot.push_back ({ node.id, node.processor, node.properties }); + + if (node.processor == nullptr) + return Result::fail ("Audio graph contains an empty node"); + + if (! nodeIndexByID.emplace (node.id.getRawID(), i).second) + return Result::fail ("Audio graph contains duplicate node IDs"); + } + + auto source = resolveEndpoint (connection.source, true, nodesSnapshot, nodeIndexByID); + if (! source) + return Result::fail (source.getErrorMessage()); + + auto destination = resolveEndpoint (connection.destination, false, nodesSnapshot, nodeIndexByID); + if (! destination) + return Result::fail (destination.getErrorMessage()); + + const auto sourceEndpoint = std::move (source).getValue(); + const auto destinationEndpoint = std::move (destination).getValue(); + + 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"); + return Result::ok(); } @@ -336,10 +386,6 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener if (details.latencyChanged) { latencyChangeCounter.fetch_add (1); - owner.updateHostDisplay (AudioProcessor::ChangeDetails().withLatencyChanged (true)); - - if (! commitInProgress.load()) - ignoreUnused (commitChanges()); } } @@ -541,16 +587,16 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener CompiledConnection connection; connection.connection = modelConnection; - const auto source = resolveEndpoint (modelConnection.source, true, nodesSnapshot, nodeIndexByID); + auto source = resolveEndpoint (modelConnection.source, true, nodesSnapshot, nodeIndexByID); if (! source) return Result::fail (source.getErrorMessage()); - const auto destination = resolveEndpoint (modelConnection.destination, false, nodesSnapshot, nodeIndexByID); + auto destination = resolveEndpoint (modelConnection.destination, false, nodesSnapshot, nodeIndexByID); if (! destination) return Result::fail (destination.getErrorMessage()); - const auto& sourceEndpoint = sourceEndpointScratch; - const auto& destinationEndpoint = destinationEndpointScratch; + const auto sourceEndpoint = std::move (source).getValue(); + const auto destinationEndpoint = std::move (destination).getValue(); if (sourceEndpoint.type != destinationEndpoint.type) return Result::fail ("Audio graph connection mixes audio and MIDI endpoints"); @@ -608,19 +654,18 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener return Result::ok(); } - Result resolveEndpoint (const AudioGraphEndpoint& endpoint, - bool source, - const std::vector& nodesSnapshot, - const std::unordered_map& nodeIndexByID) + ResultValue resolveEndpoint (const AudioGraphEndpoint& endpoint, + bool source, + const std::vector& nodesSnapshot, + const std::unordered_map& nodeIndexByID) const { - auto& result = source ? sourceEndpointScratch : destinationEndpointScratch; - result = {}; + ResolvedEndpoint result; if (source && ! endpoint.isSource()) - return Result::fail ("Audio graph endpoint is not a source"); + return makeResultValueFail ("Audio graph endpoint is not a source"); if (! source && ! endpoint.isDestination()) - return Result::fail ("Audio graph endpoint is not a destination"); + return makeResultValueFail ("Audio graph endpoint is not a destination"); Span buses; std::vector offsets; @@ -642,7 +687,7 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener { const auto iterator = nodeIndexByID.find (endpoint.getNodeID().getRawID()); if (iterator == nodeIndexByID.end()) - return Result::fail ("Audio graph connection references a missing node"); + return makeResultValueFail ("Audio graph connection references a missing node"); result.nodeIndex = iterator->second; const auto& processor = *nodesSnapshot[static_cast (result.nodeIndex)].processor; @@ -656,13 +701,13 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener } if (! isValidBusIndex (buses, endpoint.getBusIndex())) - return Result::fail ("Audio graph connection references an invalid bus index"); + return makeResultValueFail ("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(); + return makeResultValueOk (result); } Result buildTopologicalOrder (CompiledGraph& graph, @@ -1065,7 +1110,7 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener bool hasCompiledPlan() const noexcept { - return currentPlan != nullptr || pendingPlan.load() != nullptr; + return hasPublishedPlan.load(); } void synchronizeNodeListeners (const std::vector& nodesSnapshot) @@ -1136,11 +1181,10 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener int maxBlockSize = 1024; float lastCompiledSampleRate = 0.0f; int lastCompiledMaxBlockSize = 0; + std::atomic hasPublishedPlan { false }; 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 }; @@ -1214,6 +1258,11 @@ Result AudioGraphProcessor::commitChanges() return pimpl->commitChanges(); } +Result AudioGraphProcessor::validateConnection (const AudioGraphConnection& connection) const +{ + return pimpl->validateConnection (connection); +} + void AudioGraphProcessor::setNumWorkerThreads (int numThreads) { pimpl->setNumWorkerThreads (numThreads); diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h index a5254febc..5417b8ef1 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.h @@ -29,11 +29,11 @@ namespace yup Topology 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. Child processor - latency notifications trigger a rebuild so plugin latency changes can update - delay compensation. Metadata edits such as node positions and properties - are saved by the model without invalidating the compiled plan. processBlock() - only swaps pending plans at block boundaries and keeps retired plans alive until - a later control-thread commit or destruction. + latency notifications mark the graph dirty; call commitChanges() from the + control thread to rebuild delay compensation. Metadata edits such as node + positions and properties are saved by the model without invalidating the + compiled 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 { @@ -60,6 +60,9 @@ class YUP_API AudioGraphProcessor final : public AudioProcessor /** Validates, compiles, and publishes the current graph topology when needed. */ Result commitChanges(); + /** Validates one connection against the current model and graph bus layout. */ + Result validateConnection (const AudioGraphConnection& connection) const; + /** Returns true when topology edits have not yet been committed. */ bool hasUncommittedChanges() const noexcept; diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp index a3ae98ff4..8f8a24241 100644 --- a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp +++ b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp @@ -884,6 +884,13 @@ bool AudioGraphComponent::tryConnect (const AudioGraphEndpoint& first, const Aud const auto connection = makeConnection (first, second); + if (graph != nullptr) + { + const auto validation = graph->validateConnection (connection); + if (validation.failed()) + return false; + } + if (onConnectionRequested == nullptr || ! onConnectionRequested (connection)) return false; diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp index 54e3bc5ea..05f89f78f 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.cpp @@ -41,16 +41,24 @@ AudioProcessor::~AudioProcessor() void AudioProcessor::addParameter (AudioParameter::Ptr parameter) { jassert (parameter != nullptr); + if (parameter == nullptr) + return; - parameter->setIndexInContainer (static_cast (parameters.size())); + if (parameterMap.find (parameter->getID()) != parameterMap.end()) + return; - auto [iterator, inserted] = parameterMap.try_emplace (parameter->getID(), parameter); - if (! inserted) - jassertfalse; // You added a parameter with the same id twice! + parameter->setIndexInContainer (static_cast (parameters.size())); + parameterMap.emplace (parameter->getID(), parameter); parameters.emplace_back (std::move (parameter)); } +AudioParameter::Ptr AudioProcessor::getParameterByID (StringRef parameterID) const +{ + const auto iterator = parameterMap.find (String (parameterID)); + return iterator != parameterMap.end() ? iterator->second : nullptr; +} + void AudioProcessor::addListener (Listener* listener) { listeners.add (listener); @@ -103,12 +111,12 @@ void AudioProcessor::suspendProcessing (bool shouldSuspend) { auto lock = CriticalSection::ScopedLockType (processLock); - processIsSuspended = shouldSuspend; + processIsSuspended.store (shouldSuspend); } bool AudioProcessor::isSuspended() const { - return processIsSuspended; + return processIsSuspended.load(); } //============================================================================== diff --git a/modules/yup_audio_processors/processors/yup_AudioProcessor.h b/modules/yup_audio_processors/processors/yup_AudioProcessor.h index ac03106d5..fe8fe8761 100644 --- a/modules/yup_audio_processors/processors/yup_AudioProcessor.h +++ b/modules/yup_audio_processors/processors/yup_AudioProcessor.h @@ -84,6 +84,9 @@ class YUP_API AudioProcessor /** Returns the parameters. */ Span getParameters() const { return parameters; } + /** Returns a parameter by stable ID, or nullptr when no such parameter exists. */ + AudioParameter::Ptr getParameterByID (StringRef parameterID) const; + /** Adds a parameter. */ void addParameter (AudioParameter::Ptr parameter); @@ -276,7 +279,7 @@ class YUP_API AudioProcessor AudioPlayHead* playHead = nullptr; CriticalSection processLock; - bool processIsSuspended = false; + std::atomic processIsSuspended { false }; }; } // namespace yup diff --git a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp index a4522c493..b4a1766cd 100644 --- a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp +++ b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp @@ -817,7 +817,7 @@ TEST (AudioGraphProcessorTests, CommitPreparesNodes) EXPECT_EQ (128, processorPtr->preparedBlockSize); } -TEST (AudioGraphProcessorTests, RejectsMissingNodeConnection) +TEST (AudioGraphProcessorTests, RejectsMissingNodeConnectionImmediately) { auto model = std::make_shared(); AudioGraphProcessor graph (model); @@ -825,8 +825,7 @@ TEST (AudioGraphProcessorTests, RejectsMissingNodeConnection) const auto result = model->addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID::invalid(), 0), AudioGraphEndpoint::graphOutput (0) }); - EXPECT_TRUE (result.wasOk()); - EXPECT_TRUE (graph.commitChanges().failed()); + EXPECT_TRUE (result.failed()); } TEST (AudioGraphProcessorTests, RejectsCycles) @@ -999,6 +998,34 @@ TEST (AudioGraphProcessorTests, NullNodeIsRejected) EXPECT_FALSE (graph.hasUncommittedChanges()); } +TEST (AudioProcessorTests, ParameterLookupUsesUniqueIDs) +{ + TestProcessor processor; + + auto first = AudioParameterBuilder() + .withID ("gain") + .withName ("Gain") + .withRange (0.0f, 1.0f) + .withDefault (0.25f) + .build(); + + auto duplicate = AudioParameterBuilder() + .withID ("gain") + .withName ("Duplicate Gain") + .withRange (0.0f, 1.0f) + .withDefault (0.75f) + .build(); + + auto* firstRaw = first.get(); + processor.addParameter (first); + processor.addParameter (duplicate); + + EXPECT_EQ (1u, processor.getParameters().size()); + EXPECT_EQ (0, firstRaw->getIndexInContainer()); + EXPECT_EQ (firstRaw, processor.getParameterByID ("gain").get()); + EXPECT_EQ (nullptr, processor.getParameterByID ("missing").get()); +} + TEST (AudioGraphProcessorTests, RejectsInvalidEndpointDirectionImmediately) { auto model = std::make_shared(); @@ -1026,7 +1053,7 @@ TEST (AudioGraphProcessorTests, RejectsDuplicateConnectionsImmediately) EXPECT_TRUE (model->addConnection (connection).failed()); } -TEST (AudioGraphProcessorTests, RejectsInvalidBusIndexAtCommit) +TEST (AudioGraphProcessorTests, RejectsInvalidNodeBusIndexImmediately) { auto model = std::make_shared(); AudioGraphProcessor graph (model); @@ -1034,11 +1061,10 @@ TEST (AudioGraphProcessorTests, RejectsInvalidBusIndexAtCommit) EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (0), AudioGraphEndpoint::nodeInput (node, 99) }) - .wasOk()); - EXPECT_TRUE (graph.commitChanges().failed()); + .failed()); } -TEST (AudioGraphProcessorTests, RejectsAudioMidiTypeMismatchAtCommit) +TEST (AudioGraphProcessorTests, RejectsAudioMidiTypeMismatchImmediately) { auto model = std::make_shared(); AudioGraphProcessor graph (model); @@ -1047,11 +1073,10 @@ TEST (AudioGraphProcessorTests, RejectsAudioMidiTypeMismatchAtCommit) EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (audioNode, 0), AudioGraphEndpoint::nodeInput (midiNode, 0) }) - .wasOk()); - EXPECT_TRUE (graph.commitChanges().failed()); + .failed()); } -TEST (AudioGraphProcessorTests, RejectsAudioChannelMismatchAtCommit) +TEST (AudioGraphProcessorTests, RejectsAudioChannelMismatchImmediately) { auto model = std::make_shared(); AudioGraphProcessor graph (model); @@ -1060,8 +1085,35 @@ TEST (AudioGraphProcessorTests, RejectsAudioChannelMismatchAtCommit) EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::nodeOutput (stereoNode, 0), AudioGraphEndpoint::nodeInput (monoNode, 0) }) + .failed()); +} + +TEST (AudioGraphProcessorTests, RejectsInvalidGraphBusIndexAtCommit) +{ + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + const auto node = model->addNode (std::make_unique()); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (99), + AudioGraphEndpoint::nodeInput (node, 0) }) + .wasOk()); + EXPECT_TRUE (graph.commitChanges().failed()); +} + +TEST (AudioGraphProcessorTests, FailedCommitReleasesNewlyPreparedNodes) +{ + auto model = std::make_shared(); + AudioGraphProcessor graph (model); + auto processor = std::make_unique(); + auto* processorPtr = processor.get(); + const auto node = model->addNode (std::move (processor)); + + EXPECT_TRUE (model->addConnection ({ AudioGraphEndpoint::graphInput (99), + AudioGraphEndpoint::nodeInput (node, 0) }) .wasOk()); + EXPECT_TRUE (graph.commitChanges().failed()); + EXPECT_FALSE (processorPtr->prepared); } TEST (AudioGraphProcessorTests, MissingCommitKeepsPreviousPlan) @@ -1126,6 +1178,11 @@ TEST (AudioGraphProcessorTests, ExternalTopologyEditsDriveDirtyRevision) ASSERT_NE (nullptr, processor); processor->setLatencySamplesForTest (16); + EXPECT_TRUE (graph.hasUncommittedChanges()); + EXPECT_EQ (0, graph.getLatencySamples()); + EXPECT_EQ (latencyQueriesAfterCommit, latencyQueryCount.load()); + + EXPECT_TRUE (graph.commitChanges().wasOk()); EXPECT_FALSE (graph.hasUncommittedChanges()); EXPECT_EQ (16, graph.getLatencySamples()); EXPECT_GT (latencyQueryCount.load(), latencyQueriesAfterCommit); @@ -1389,7 +1446,7 @@ TEST (AudioGraphProcessorTests, LoadStateFailureRestoresPreviousGraphModel) { auto invalidSourceModel = std::make_shared(); AudioGraphProcessor invalidSource (invalidSourceModel); - ASSERT_TRUE (invalidSourceModel->addConnection ({ AudioGraphEndpoint::nodeOutput (AudioGraphNodeID (999), 0), + ASSERT_TRUE (invalidSourceModel->addConnection ({ AudioGraphEndpoint::graphInput (99), AudioGraphEndpoint::graphOutput (0) }) .wasOk()); diff --git a/tests/yup_audio_gui/yup_AudioGraphComponent.cpp b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp index 124df5593..155083edd 100644 --- a/tests/yup_audio_gui/yup_AudioGraphComponent.cpp +++ b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp @@ -39,6 +39,12 @@ AudioBusLayout stereoLayout() { 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) }); +} + void expectPointNear (Point actual, Point expected, float tolerance = pointTolerance) { EXPECT_NEAR (expected.getX(), actual.getX(), tolerance); @@ -86,6 +92,37 @@ class TestProcessor : public AudioProcessor bool hasEditor() const override { return false; } }; +class MonoProcessor : public AudioProcessor +{ +public: + MonoProcessor() + : AudioProcessor ("Graph Component Mono Processor", 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 TestNodeView : public AudioGraphNodeView { public: @@ -752,6 +789,25 @@ TEST_F (AudioGraphComponentTests, IncompatibleEndpointPairKeepsNewEndpointArmedA EXPECT_EQ (secondInput, *component->getPendingWireEndpoint()); } +TEST_F (AudioGraphComponentTests, IncompatibleProcessorLayoutsDoNotAddConnection) +{ + const auto stereoNodeID = addProcessorNode(); + const auto monoNodeID = model->addNode (std::make_unique()); + + addNodeView (stereoNodeID, { 100.0f, 50.0f }); + addNodeView (monoNodeID, { 320.0f, 50.0f }); + + const auto stereoOutput = AudioGraphEndpoint::nodeOutput (stereoNodeID, 0); + const auto monoInput = AudioGraphEndpoint::nodeInput (monoNodeID, 0); + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (stereoOutput))); + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (monoInput))); + + EXPECT_TRUE (model->getConnections().empty()); + ASSERT_TRUE (component->getPendingWireEndpoint().has_value()); + EXPECT_EQ (monoInput, *component->getPendingWireEndpoint()); +} + TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesAllConnectionsForEndpoint) { const auto nodeID = addProcessorNode(); From 3a1b768f0297a629f5bab29f89810adee4e8892f Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 20 May 2026 23:01:38 +0200 Subject: [PATCH 06/14] Fix audio thumbnail data races --- .../waveform/yup_AudioThumbnail.h | 2 +- tests/yup_audio_gui/yup_AudioThumbnail.cpp | 40 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/modules/yup_audio_gui/waveform/yup_AudioThumbnail.h b/modules/yup_audio_gui/waveform/yup_AudioThumbnail.h index b558af48d..f68196d6e 100644 --- a/modules/yup_audio_gui/waveform/yup_AudioThumbnail.h +++ b/modules/yup_audio_gui/waveform/yup_AudioThumbnail.h @@ -207,7 +207,7 @@ class YUP_API AudioThumbnail : public AudioPeakProfileCache::Listener std::atomic progressVisible { false }; // Listeners - ListenerList listeners; + ListenerList> listeners; WeakReference::Master masterReference; friend class WeakReference; diff --git a/tests/yup_audio_gui/yup_AudioThumbnail.cpp b/tests/yup_audio_gui/yup_AudioThumbnail.cpp index 7ffe33b7b..5a6fc9778 100644 --- a/tests/yup_audio_gui/yup_AudioThumbnail.cpp +++ b/tests/yup_audio_gui/yup_AudioThumbnail.cpp @@ -21,6 +21,8 @@ #include +#include + #include using namespace yup; @@ -58,21 +60,21 @@ class ThumbnailTestListener : public AudioThumbnail::Listener void thumbnailChanged (AudioThumbnail& thumb) override { ignoreUnused (thumb); - changedCallCount++; + changedCallCount.fetch_add (1); } void thumbnailProgressChanged (AudioThumbnail& thumb, double progress, bool visible) override { ignoreUnused (thumb); - lastProgress = progress; - lastProgressVisible = visible; - progressCallCount++; + lastProgress.store (progress); + lastProgressVisible.store (visible); + progressCallCount.fetch_add (1); } - int changedCallCount = 0; - int progressCallCount = 0; - double lastProgress = 0.0; - bool lastProgressVisible = false; + std::atomic changedCallCount { 0 }; + std::atomic progressCallCount { 0 }; + std::atomic lastProgress { 0.0 }; + std::atomic lastProgressVisible { false }; }; } // namespace @@ -149,7 +151,7 @@ TEST_F (AudioThumbnailTests, SetSourceWithBufferPointer) waitForProfileReady(); EXPECT_NE (nullptr, thumbnail->getPeakProfile()); - EXPECT_GE (listener->changedCallCount, 1); + EXPECT_GE (listener->changedCallCount.load(), 1); } TEST_F (AudioThumbnailTests, SetSourceWithBufferByReference) @@ -229,7 +231,7 @@ TEST_F (AudioThumbnailTests, ProgressNotifications) syncThumbnail.setSource (&buffer, kThumbnailSampleRate); // Should have received progress updates - EXPECT_GT (syncListener.progressCallCount, 0); + EXPECT_GT (syncListener.progressCallCount.load(), 0); } TEST_F (AudioThumbnailTests, GetProgressReturnsValue) @@ -264,7 +266,7 @@ TEST_F (AudioThumbnailTests, SharedCacheBetweenThumbnails) runDispatchLoopUntil (100); EXPECT_NE (nullptr, thumbnail2.getPeakProfile()); // Gets 2 notifications: one from setSource, one from profileReady - EXPECT_GE (listener2.changedCallCount, 1); + EXPECT_GE (listener2.changedCallCount.load(), 1); } TEST_F (AudioThumbnailTests, DiskCacheIntegration) @@ -314,23 +316,23 @@ TEST_F (AudioThumbnailTests, MultipleSetSourceCallsHandledCorrectly) waitForProfileReady(); EXPECT_EQ (1, thumbnail->getNumChannels()); - listener->changedCallCount = 0; + listener->changedCallCount.store (0); thumbnail->setSource (&buffer2, kThumbnailSampleRate); waitForProfileReady(); EXPECT_EQ (2, thumbnail->getNumChannels()); - EXPECT_GE (listener->changedCallCount, 1); + EXPECT_GE (listener->changedCallCount.load(), 1); } TEST_F (AudioThumbnailTests, ListenerNotificationsWork) { auto buffer = createThumbnailTestBuffer (1, kThumbnailBufferSize); - int initialChangedCount = listener->changedCallCount; + int initialChangedCount = listener->changedCallCount.load(); thumbnail->setSource (&buffer, kThumbnailSampleRate); waitForProfileReady(); - EXPECT_GT (listener->changedCallCount, initialChangedCount); + EXPECT_GT (listener->changedCallCount.load(), initialChangedCount); } TEST_F (AudioThumbnailTests, RemoveListenerStopsNotifications) @@ -341,7 +343,7 @@ TEST_F (AudioThumbnailTests, RemoveListenerStopsNotifications) thumbnail->setSource (&buffer, kThumbnailSampleRate); waitForProfileReady(); - EXPECT_EQ (0, listener->changedCallCount); + EXPECT_EQ (0, listener->changedCallCount.load()); } TEST_F (AudioThumbnailTests, SetSourceWithZeroSampleRateUsesDefault) @@ -900,9 +902,9 @@ TEST_F (AudioThumbnailTests, MultipleListeners) waitForProfileReady(); // All listeners should be notified - EXPECT_GT (listener->changedCallCount, 0); - EXPECT_GT (listener2.changedCallCount, 0); - EXPECT_GT (listener3.changedCallCount, 0); + EXPECT_GT (listener->changedCallCount.load(), 0); + EXPECT_GT (listener2.changedCallCount.load(), 0); + EXPECT_GT (listener3.changedCallCount.load(), 0); } //============================================================================== From 7b40afca3f9d341dcb3fac7e0314f71aa2b6f1ff Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 20 May 2026 23:01:54 +0200 Subject: [PATCH 07/14] Fix buffering audio source data race in tests --- .../yup_BufferingAudioSource.cpp | 118 ++++++++++-------- 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/tests/yup_audio_basics/yup_BufferingAudioSource.cpp b/tests/yup_audio_basics/yup_BufferingAudioSource.cpp index 05eb16710..cab214424 100644 --- a/tests/yup_audio_basics/yup_BufferingAudioSource.cpp +++ b/tests/yup_audio_basics/yup_BufferingAudioSource.cpp @@ -21,6 +21,8 @@ #include +#include + #include using namespace yup; @@ -42,68 +44,82 @@ class MockPositionableAudioSource : public PositionableAudioSource void prepareToPlay (int samplesPerBlockExpected, double sampleRate) override { - prepareToPlayCalled = true; - lastSamplesPerBlock = samplesPerBlockExpected; - lastSampleRate = sampleRate; + prepareToPlayCalled.store (true); + lastSamplesPerBlock.store (samplesPerBlockExpected); + lastSampleRate.store (sampleRate); } void releaseResources() override { - releaseResourcesCalled = true; + releaseResourcesCalled.store (true); } void getNextAudioBlock (const AudioSourceChannelInfo& info) override { - getNextAudioBlockCalled = true; + getNextAudioBlockCalled.store (true); + + const auto startPosition = currentPosition.fetch_add (info.numSamples); // Fill with a pattern based on current position for (int ch = 0; ch < info.buffer->getNumChannels(); ++ch) { for (int i = 0; i < info.numSamples; ++i) { - const float value = std::sin ((currentPosition + i) * 0.01f) * 0.5f; + const float value = std::sin ((startPosition + i) * 0.01f) * 0.5f; info.buffer->setSample (ch, info.startSample + i, value); } } - currentPosition += info.numSamples; } void setNextReadPosition (int64 newPosition) override { - setNextReadPositionCalled = true; - currentPosition = newPosition; + setNextReadPositionCalled.store (true); + currentPosition.store (newPosition); } int64 getNextReadPosition() const override { - return currentPosition; + return currentPosition.load(); } int64 getTotalLength() const override { - return totalLength; + return totalLength.load(); } bool isLooping() const override { - return looping; + return looping.load(); } void setLooping (bool shouldLoop) override { - looping = shouldLoop; + looping.store (shouldLoop); } - bool prepareToPlayCalled = false; - bool releaseResourcesCalled = false; - bool getNextAudioBlockCalled = false; - bool setNextReadPositionCalled = false; - int lastSamplesPerBlock = 0; - double lastSampleRate = 0.0; - int64 totalLength; - int64 currentPosition; - bool looping; + std::atomic prepareToPlayCalled { false }; + std::atomic releaseResourcesCalled { false }; + std::atomic getNextAudioBlockCalled { false }; + std::atomic setNextReadPositionCalled { false }; + std::atomic lastSamplesPerBlock { 0 }; + std::atomic lastSampleRate { 0.0 }; + std::atomic totalLength; + std::atomic currentPosition; + std::atomic looping; }; + +bool waitForFlag (const std::atomic& flag, int timeoutMs = 1000) +{ + for (int elapsedMs = 0; elapsedMs < timeoutMs; elapsedMs += 5) + { + if (flag.load()) + return true; + + Thread::sleep (5); + } + + return flag.load(); +} } // namespace //============================================================================== @@ -173,9 +189,9 @@ TEST_F (BufferingAudioSourceTests, PrepareToPlay) buffering->prepareToPlay (512, 44100.0); // Should call prepareToPlay on source (line 80) - EXPECT_TRUE (mockSource->prepareToPlayCalled); - EXPECT_EQ (mockSource->lastSamplesPerBlock, 512); - EXPECT_DOUBLE_EQ (mockSource->lastSampleRate, 44100.0); + EXPECT_TRUE (mockSource->prepareToPlayCalled.load()); + EXPECT_EQ (mockSource->lastSamplesPerBlock.load(), 512); + EXPECT_DOUBLE_EQ (mockSource->lastSampleRate.load(), 44100.0); // Give background thread time to start buffering Thread::sleep (50); @@ -190,7 +206,7 @@ TEST_F (BufferingAudioSourceTests, PrepareToPlayMultipleTimes) buffering->prepareToPlay (512, 44100.0); Thread::sleep (50); - EXPECT_TRUE (mockSource->prepareToPlayCalled); + EXPECT_TRUE (mockSource->prepareToPlayCalled.load()); } TEST_F (BufferingAudioSourceTests, PrepareToPlayDifferentSampleRate) @@ -202,7 +218,7 @@ TEST_F (BufferingAudioSourceTests, PrepareToPlayDifferentSampleRate) buffering->prepareToPlay (512, 48000.0); Thread::sleep (50); - EXPECT_DOUBLE_EQ (mockSource->lastSampleRate, 48000.0); + EXPECT_DOUBLE_EQ (mockSource->lastSampleRate.load(), 48000.0); } TEST_F (BufferingAudioSourceTests, PrepareToPlayDifferentBufferSize) @@ -217,7 +233,7 @@ TEST_F (BufferingAudioSourceTests, PrepareToPlayDifferentBufferSize) Thread::sleep (50); // The source might still have old value if buffer didn't need resize - EXPECT_GE (mockSource->lastSamplesPerBlock, 512); + EXPECT_GE (mockSource->lastSamplesPerBlock.load(), 512); } TEST_F (BufferingAudioSourceTests, PrepareToPlayWithPrefill) @@ -229,7 +245,7 @@ TEST_F (BufferingAudioSourceTests, PrepareToPlayWithPrefill) // This should block until buffer is partially filled (line 98-99) bufferingWithPrefill->prepareToPlay (512, 44100.0); - EXPECT_TRUE (source->prepareToPlayCalled); + EXPECT_TRUE (source->prepareToPlayCalled.load()); } //============================================================================== @@ -241,7 +257,7 @@ TEST_F (BufferingAudioSourceTests, ReleaseResources) buffering->releaseResources(); // Should call releaseResources on source (line 114) - EXPECT_TRUE (mockSource->releaseResourcesCalled); + EXPECT_TRUE (mockSource->releaseResourcesCalled.load()); } //============================================================================== @@ -376,7 +392,7 @@ TEST_F (BufferingAudioSourceTests, GetNextAudioBlockWithStartSample) TEST_F (BufferingAudioSourceTests, WaitForNextAudioBlockReadyNullSource) { // Create buffering with source that has zero length - mockSource->totalLength = 0; + mockSource->totalLength.store (0); AudioBuffer buffer (2, 512); AudioSourceChannelInfo info; @@ -478,11 +494,11 @@ TEST_F (BufferingAudioSourceTests, GetNextReadPosition) TEST_F (BufferingAudioSourceTests, GetNextReadPositionWithLooping) { + mockSource->setLooping (true); + buffering->prepareToPlay (512, 44100.0); Thread::sleep (50); - mockSource->setLooping (true); - // Set position past total length buffering->setNextReadPosition (mockSource->getTotalLength() + 1000); @@ -521,24 +537,20 @@ TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkInitial) { buffering->prepareToPlay (512, 44100.0); - // Background thread should call readNextBufferChunk (line 238-318) - Thread::sleep (100); - - // Verify source was called - EXPECT_TRUE (mockSource->getNextAudioBlockCalled); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); } TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkCacheMiss) { buffering->prepareToPlay (512, 44100.0); - Thread::sleep (100); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); + + mockSource->setNextReadPositionCalled.store (false); // Seek far away to trigger cache miss (line 259-268) buffering->setNextReadPosition (200000); - Thread::sleep (100); - // Should have read new buffer section - EXPECT_TRUE (mockSource->setNextReadPositionCalled); + EXPECT_TRUE (waitForFlag (mockSource->setNextReadPositionCalled)); } TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkIncrementalRead) @@ -561,7 +573,7 @@ TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkIncrementalRead) } // Should trigger incremental reads (line 269-279) - EXPECT_TRUE (mockSource->getNextAudioBlockCalled); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); } TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkWrapAround) @@ -587,16 +599,18 @@ TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkWrapAround) TEST_F (BufferingAudioSourceTests, ReadNextBufferChunkLoopingChange) { buffering->prepareToPlay (512, 44100.0); - Thread::sleep (100); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); + + mockSource->getNextAudioBlockCalled.store (false); // Change looping state to trigger buffer reset (line 245-250) mockSource->setLooping (true); - Thread::sleep (100); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); + mockSource->getNextAudioBlockCalled.store (false); mockSource->setLooping (false); - Thread::sleep (100); - EXPECT_TRUE (mockSource->getNextAudioBlockCalled); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); } //============================================================================== @@ -604,11 +618,7 @@ TEST_F (BufferingAudioSourceTests, UseTimeSlice) { buffering->prepareToPlay (512, 44100.0); - // useTimeSlice is called by background thread (line 331-334) - Thread::sleep (100); - - // Should have processed some chunks - EXPECT_TRUE (mockSource->getNextAudioBlockCalled); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); } TEST_F (BufferingAudioSourceTests, MultipleChannels) @@ -649,7 +659,7 @@ TEST_F (BufferingAudioSourceTests, StressTestContinuousPlayback) Thread::sleep (5); } - EXPECT_TRUE (mockSource->getNextAudioBlockCalled); + EXPECT_TRUE (waitForFlag (mockSource->getNextAudioBlockCalled)); } TEST_F (BufferingAudioSourceTests, StressTestRandomSeeks) @@ -676,5 +686,5 @@ TEST_F (BufferingAudioSourceTests, StressTestRandomSeeks) buffering->getNextAudioBlock (info); } - EXPECT_TRUE (mockSource->setNextReadPositionCalled); + EXPECT_TRUE (waitForFlag (mockSource->setNextReadPositionCalled)); } From 1d71b020bc7aa88b5ec61a62276e30207d6e81f8 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Wed, 20 May 2026 23:02:08 +0200 Subject: [PATCH 08/14] Fix data race in URL on macos --- .../yup_core/native/yup_BasicNativeHeaders.h | 1 + modules/yup_core/native/yup_Network_apple.mm | 130 +++++++++++------- 2 files changed, 80 insertions(+), 51 deletions(-) diff --git a/modules/yup_core/native/yup_BasicNativeHeaders.h b/modules/yup_core/native/yup_BasicNativeHeaders.h index 5d98ec23b..4e1331f23 100644 --- a/modules/yup_core/native/yup_BasicNativeHeaders.h +++ b/modules/yup_core/native/yup_BasicNativeHeaders.h @@ -78,6 +78,7 @@ #include #include #include +#include #include //============================================================================== diff --git a/modules/yup_core/native/yup_Network_apple.mm b/modules/yup_core/native/yup_Network_apple.mm index 8d665c448..ef71c8610 100644 --- a/modules/yup_core/native/yup_Network_apple.mm +++ b/modules/yup_core/native/yup_Network_apple.mm @@ -416,18 +416,19 @@ class API_AVAILABLE(macos(10.9)) URLConnectionState final : public URLConnection DelegateClass::setState(delegate, this); } - ~URLConnectionState() override - { - signalThreadShouldExit(); - - { - const ScopedLock sl(dataLock); - isBeingDeleted = true; - [task cancel]; - DelegateClass::setState(delegate, nullptr); - } - - stopThread(10000); + ~URLConnectionState() override + { + signalThreadShouldExit(); + + DelegateClass::setState(delegate, nullptr); + + { + const ScopedLock sl(dataLock); + isBeingDeleted = true; + [task cancel]; + } + + stopThread(10000); [session finishTasksAndInvalidate]; @@ -625,45 +626,72 @@ void run() override registerClass(); } - static void setState(id self, URLConnectionState* state) { setIvar(self, "state", state); } - static URLConnectionState* getState(id self) { return getIvar(self, "state"); } - - private: - static void didReceiveResponse(id self, SEL, NSURLSession*, NSURLSessionDataTask*, NSURLResponse* response, id completionHandler) - { - if (auto state = getState(self)) - state->didReceiveResponse(response, completionHandler); - } - - static void didBecomeInvalidWithError(id self, SEL, NSURLSession*, NSError* error) - { - if (auto state = getState(self)) - state->didComplete(error); - } - - static void didReceiveData(id self, SEL, NSURLSession*, NSURLSessionDataTask*, NSData* newData) - { - if (auto state = getState(self)) - state->didReceiveData(newData); - } - - static void didSendBodyData(id self, SEL, NSURLSession*, NSURLSessionTask*, int64_t, int64_t totalBytesWritten, int64_t) - { - if (auto state = getState(self)) - state->didSendBodyData(totalBytesWritten); - } - - static void willPerformHTTPRedirection(id self, SEL, NSURLSession*, NSURLSessionTask*, NSHTTPURLResponse*, NSURLRequest* request, void (^completionHandler)(NSURLRequest*)) - { - if (auto state = getState(self)) - state->willPerformHTTPRedirection(request, completionHandler); - } - - static void didCompleteWithError(id self, SEL, NSURLConnection*, NSURLSessionTask*, NSError* error) - { - if (auto state = getState(self)) - state->didComplete(error); - } + static void setState(id self, URLConnectionState* state) + { + objc_sync_enter(self); + const ScopeGuard unlock { [self] { objc_sync_exit(self); } }; + + setIvar(self, "state", state); + } + + private: + template + static void withState(id self, Callback&& callback) + { + objc_sync_enter(self); + const ScopeGuard unlock { [self] { objc_sync_exit(self); } }; + + if (auto* state = getIvar(self, "state")) + callback(*state); + } + + static void didReceiveResponse(id self, SEL, NSURLSession*, NSURLSessionDataTask*, NSURLResponse* response, id completionHandler) + { + withState(self, [&] (URLConnectionState& state) + { + state.didReceiveResponse(response, completionHandler); + }); + } + + static void didBecomeInvalidWithError(id self, SEL, NSURLSession*, NSError* error) + { + withState(self, [&] (URLConnectionState& state) + { + state.didComplete(error); + }); + } + + static void didReceiveData(id self, SEL, NSURLSession*, NSURLSessionDataTask*, NSData* newData) + { + withState(self, [&] (URLConnectionState& state) + { + state.didReceiveData(newData); + }); + } + + static void didSendBodyData(id self, SEL, NSURLSession*, NSURLSessionTask*, int64_t, int64_t totalBytesWritten, int64_t) + { + withState(self, [&] (URLConnectionState& state) + { + state.didSendBodyData(totalBytesWritten); + }); + } + + static void willPerformHTTPRedirection(id self, SEL, NSURLSession*, NSURLSessionTask*, NSHTTPURLResponse*, NSURLRequest* request, void (^completionHandler)(NSURLRequest*)) + { + withState(self, [&] (URLConnectionState& state) + { + state.willPerformHTTPRedirection(request, completionHandler); + }); + } + + static void didCompleteWithError(id self, SEL, NSURLConnection*, NSURLSessionTask*, NSError* error) + { + withState(self, [&] (URLConnectionState& state) + { + state.didComplete(error); + }); + } }; NSURLSession* session = nil; From ed6cdba78669d2999248beee100c051dffe7ff70 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 00:13:19 +0200 Subject: [PATCH 09/14] Undo support --- examples/audiograph/source/AudioGraphApp.h | 89 +++--- .../source/nodes/SoundCardInputNodeView.h | 2 +- .../source/nodes/SoundCardOutputNodeView.h | 2 +- .../graph/yup_AudioGraphModel.cpp | 62 +++- .../graph/yup_AudioGraphModel.h | 29 +- .../graph/yup_AudioGraphProcessor.cpp | 12 +- .../graph/yup_AudioGraphComponent.cpp | 269 ++++++++++++++++-- .../graph/yup_AudioGraphComponent.h | 45 ++- .../yup_audio_gui/yup_AudioGraphComponent.cpp | 160 +++++++++++ 9 files changed, 592 insertions(+), 78 deletions(-) diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h index 0166bd789..40917e9e5 100644 --- a/examples/audiograph/source/AudioGraphApp.h +++ b/examples/audiograph/source/AudioGraphApp.h @@ -68,7 +68,7 @@ class AudioGraphApp final model->setNodeFactory (nodeRegistry.makeProcessorFactory()); - graphComponent = std::make_unique (graph); + graphComponent = std::make_unique (graph, &undoManager); graphComponent->onConnectionRequested = [this] (const yup::AudioGraphConnection& connection) { @@ -209,11 +209,6 @@ class AudioGraphApp final //============================================================================== // AudioGraphComponent::Listener - void nodeViewMoved (yup::AudioGraphNodeID nodeID, yup::Point newCanvasPos) override - { - model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); - } - void nodeContextMenu (yup::AudioGraphNodeID nodeID, yup::Point) override { removeNode (nodeID); @@ -267,6 +262,46 @@ class AudioGraphApp final addAndMakeVisible (statusLabel); } + void clearNodeViews() + { + for (auto& [nodeID, _] : loadedNodes) + graphComponent->removeNodeView (nodeID); + + loadedNodes.clear(); + } + + void reloadGraphViewsFromModel() + { + clearNodeViews(); + + for (auto nodeID : model->getNodeIDs()) + { + auto props = model->getNodeProperties (nodeID); + if (! props.has_value()) + continue; + + auto* proc = model->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); + + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), { props->positionX, props->positionY }); + loadedNodes[nodeID] = props->identifier; + } + } + } + + void reloadGraphBoundaryViewsFromModel() + { + const auto input = model->getNodeProperties (yup::AudioGraphModel::getGraphInputNodeID()) + .value_or (yup::AudioGraphNodeProperties {}); + graphComponent->setGraphInputView (std::make_unique(), { input.positionX, input.positionY }); + + const auto output = model->getNodeProperties (yup::AudioGraphModel::getGraphOutputNodeID()) + .value_or (yup::AudioGraphNodeProperties {}); + graphComponent->setGraphOutputView (std::make_unique(), { output.positionX, output.positionY }); + } + //============================================================================== void resetToEmptyGraph() @@ -274,16 +309,15 @@ class AudioGraphApp final closePluginEditor(); closeAllSubgraphEditors(); - for (auto& [nodeID, _] : loadedNodes) - graphComponent->removeNodeView (nodeID); - - loadedNodes.clear(); + clearNodeViews(); model->clear(); + model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); + model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); graph->commitChanges(); + undoManager.clear(); currentFilePath = yup::File(); - graphComponent->setGraphInputView (std::make_unique(), { 40.0f, 200.0f }); - graphComponent->setGraphOutputView (std::make_unique(), { 760.0f, 200.0f }); + reloadGraphBoundaryViewsFromModel(); graphComponent->zoomToFitNodes(); } @@ -368,9 +402,7 @@ class AudioGraphApp final closePluginEditor(); closeAllSubgraphEditors(); - for (auto& [nodeID, _] : loadedNodes) - graphComponent->removeNodeView (nodeID); - loadedNodes.clear(); + clearNodeViews(); model->clear(); @@ -382,27 +414,14 @@ class AudioGraphApp final return; } + model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); + model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); graph->commitChanges(); + undoManager.clear(); currentFilePath = file; - for (auto nodeID : model->getNodeIDs()) - { - auto props = model->getNodeProperties (nodeID); - if (! props.has_value()) - continue; - - auto* proc = model->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); - - 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 }); + reloadGraphViewsFromModel(); + reloadGraphBoundaryViewsFromModel(); graphComponent->zoomToFitNodes(); statusLabel.setText ("Loaded: " + file.getFileName(), yup::dontSendNotification); @@ -579,6 +598,9 @@ class AudioGraphApp final void removeNode (yup::AudioGraphNodeID nodeID) { + if (model->getNodeProcessor (nodeID) == nullptr) + return; + if (activePluginEditorGraph == graph.get() && activePluginEditorNodeID == nodeID) closePluginEditor(); @@ -1022,6 +1044,7 @@ class AudioGraphApp final yup::AudioDeviceManager deviceManager; std::shared_ptr graph; std::shared_ptr model; + yup::UndoManager undoManager; std::unique_ptr graphComponent; NodeRegistry nodeRegistry; std::map loadedNodes; diff --git a/examples/audiograph/source/nodes/SoundCardInputNodeView.h b/examples/audiograph/source/nodes/SoundCardInputNodeView.h index 65c1f2b8a..28780af97 100644 --- a/examples/audiograph/source/nodes/SoundCardInputNodeView.h +++ b/examples/audiograph/source/nodes/SoundCardInputNodeView.h @@ -26,7 +26,7 @@ class SoundCardInputNodeView final : public yup::AudioGraphNodeView { public: explicit SoundCardInputNodeView (yup::StringRef subtitleIn = "sound card") - : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + : AudioGraphNodeView (yup::AudioGraphModel::getGraphInputNodeID()) , subtitle (subtitleIn) { } diff --git a/examples/audiograph/source/nodes/SoundCardOutputNodeView.h b/examples/audiograph/source/nodes/SoundCardOutputNodeView.h index 9263cb89f..48ec7c153 100644 --- a/examples/audiograph/source/nodes/SoundCardOutputNodeView.h +++ b/examples/audiograph/source/nodes/SoundCardOutputNodeView.h @@ -26,7 +26,7 @@ class SoundCardOutputNodeView final : public yup::AudioGraphNodeView { public: explicit SoundCardOutputNodeView (yup::StringRef subtitleIn = "sound card") - : AudioGraphNodeView (yup::AudioGraphNodeID::invalid()) + : AudioGraphNodeView (yup::AudioGraphModel::getGraphOutputNodeID()) , subtitle (subtitleIn) { } diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp index f23c2bf9b..5453a3434 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp @@ -94,6 +94,14 @@ AudioGraphNodeProperties modelMakeDefaultNodeProperties (const AudioProcessor& p return properties; } +AudioGraphNodeProperties modelMakeBoundaryNodeProperties (AudioGraphModel::NodeKind kind) +{ + AudioGraphNodeProperties properties; + properties.identifier = kind == AudioGraphModel::NodeKind::graphInput ? "graphInput" : "graphOutput"; + properties.name = kind == AudioGraphModel::NodeKind::graphInput ? "Graph Input" : "Graph Output"; + return properties; +} + void modelWriteBase64Element (XmlElement& parent, const char* tagName, const MemoryBlock& block) { auto* element = new XmlElement (tagName); @@ -248,6 +256,7 @@ DataTree modelCreateEndpointTree (const Identifier& type, const AudioGraphEndpoi //============================================================================== AudioGraphModel::AudioGraphModel() { + resetBoundaryNodes(); rebuildDataTree(); } @@ -274,7 +283,7 @@ AudioGraphNodeID AudioGraphModel::addNode (std::unique_ptr proce if (properties.name.isEmpty()) properties.name = processor->getName(); - nodes.push_back ({ id, std::shared_ptr (std::move (processor)), std::move (properties) }); + nodes.push_back ({ NodeKind::processor, id, std::shared_ptr (std::move (processor)), std::move (properties) }); markTopologyChanged(); return id; } @@ -288,7 +297,7 @@ bool AudioGraphModel::removeNode (AudioGraphNodeID nodeID) const auto nodeIterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) { - return node.id == nodeID; + return node.kind == NodeKind::processor && node.id == nodeID; }); if (nodeIterator == nodes.end()) @@ -320,7 +329,7 @@ Result AudioGraphModel::replaceNode (AudioGraphNodeID nodeID, const auto nodeIterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const ModelNode& node) { - return node.id == nodeID; + return node.kind == NodeKind::processor && node.id == nodeID; }); if (nodeIterator == nodes.end()) @@ -384,7 +393,12 @@ void AudioGraphModel::clear() { const std::lock_guard lock (mutex); - nodes.clear(); + nodes.erase (std::remove_if (nodes.begin(), nodes.end(), [] (const ModelNode& node) + { + return node.kind == NodeKind::processor; + }), + nodes.end()); + resetBoundaryNodes(); connections.clear(); markTopologyChanged(); } @@ -418,6 +432,7 @@ bool AudioGraphModel::setNodePosition (AudioGraphNodeID nodeID, float positionX, iterator->properties.positionX = positionX; iterator->properties.positionY = positionY; + markMetadataChanged(); return true; } @@ -465,7 +480,8 @@ std::vector AudioGraphModel::getNodeIDs() const result.reserve (nodes.size()); for (const auto& node : nodes) - result.push_back (node.id); + if (node.kind == NodeKind::processor) + result.push_back (node.id); return result; } @@ -485,6 +501,9 @@ ResultValue> AudioGraphModel::createXml() const for (const auto& node : snapshot.nodes) { + if (node.kind != NodeKind::processor) + continue; + if (node.processor == nullptr) return makeResultValueFail ("Audio graph contains an empty node"); @@ -605,7 +624,7 @@ AudioGraphModel::Snapshot AudioGraphModel::createSnapshot() const snapshot.nodes.reserve (nodes.size()); for (const auto& node : nodes) - snapshot.nodes.push_back ({ node.id, node.processor, node.properties }); + snapshot.nodes.push_back ({ node.kind, node.id, node.processor, node.properties }); snapshot.connections = connections; snapshot.nextNodeID = nextNodeID; @@ -622,7 +641,9 @@ void AudioGraphModel::restoreSnapshot (AudioGraphModel::Snapshot snapshot) nodes.reserve (snapshot.nodes.size()); for (auto& node : snapshot.nodes) - nodes.push_back ({ node.id, std::move (node.processor), std::move (node.properties) }); + nodes.push_back ({ node.kind, node.id, std::move (node.processor), std::move (node.properties) }); + + resetBoundaryNodes(); connections = std::move (snapshot.connections); nextNodeID = snapshot.nextNodeID; @@ -667,7 +688,8 @@ Result AudioGraphModel::restoreModel (uint64_t savedNextNodeID, if (! result) return Result::fail ("Audio graph node state load failed: " + result.getErrorMessage()); - loadedNodes.push_back ({ savedNode.id, + loadedNodes.push_back ({ NodeKind::processor, + savedNode.id, std::shared_ptr (std::move (processor)), std::move (savedNode.properties) }); } @@ -680,6 +702,7 @@ Result AudioGraphModel::restoreModel (uint64_t savedNextNodeID, const std::lock_guard lock (mutex); nodes = std::move (loadedNodes); connections = std::move (savedConnections); + resetBoundaryNodes(); nextNodeID = jmax (savedNextNodeID, highestSavedNodeID); markTopologyChanged(); } @@ -702,6 +725,29 @@ ResultValue> AudioGraphModel::createProcessorFor return factoryCopy (properties); } +void AudioGraphModel::resetBoundaryNodes() +{ + auto inputProperties = modelMakeBoundaryNodeProperties (NodeKind::graphInput); + auto outputProperties = modelMakeBoundaryNodeProperties (NodeKind::graphOutput); + + for (const auto& node : nodes) + { + if (node.kind == NodeKind::graphInput) + inputProperties = node.properties; + else if (node.kind == NodeKind::graphOutput) + outputProperties = node.properties; + } + + nodes.erase (std::remove_if (nodes.begin(), nodes.end(), [] (const ModelNode& node) + { + return node.kind == NodeKind::graphInput || node.kind == NodeKind::graphOutput; + }), + nodes.end()); + + nodes.insert (nodes.begin(), { NodeKind::graphInput, getGraphInputNodeID(), nullptr, std::move (inputProperties) }); + nodes.insert (nodes.begin() + 1, { NodeKind::graphOutput, getGraphOutputNodeID(), nullptr, std::move (outputProperties) }); +} + Result AudioGraphModel::validateConnectionLocked (const AudioGraphConnection& connection) const { if (! connection.source.isSource()) diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.h b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h index c45f2c8fd..8df3da370 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.h +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.h @@ -44,9 +44,25 @@ class YUP_API AudioGraphModel final /** Creates a processor for a saved node. */ using NodeFactory = std::function> (const AudioGraphNodeProperties&)>; + /** Node categories owned by the graph model. */ + enum class NodeKind + { + /** A processor node that participates in audio graph compilation. */ + processor, + + /** The graph input boundary editor node. */ + graphInput, + + /** The graph output boundary editor node. */ + graphOutput + }; + /** Immutable node entry returned by createSnapshot(). */ struct NodeSnapshot { + /** Node category. */ + NodeKind kind = NodeKind::processor; + /** Stable node identifier. */ AudioGraphNodeID id; @@ -83,6 +99,13 @@ class YUP_API AudioGraphModel final /** Destructs the graph model. */ ~AudioGraphModel(); + //============================================================================== + /** Returns the stable graph input boundary node identifier. */ + static constexpr AudioGraphNodeID getGraphInputNodeID() noexcept { return AudioGraphNodeID (0xfffffffffffffffeull); } + + /** Returns the stable graph output boundary node identifier. */ + static constexpr AudioGraphNodeID getGraphOutputNodeID() noexcept { return AudioGraphNodeID (0xffffffffffffffffull); } + //============================================================================== /** Adds a processor node and returns its stable node identifier. */ AudioGraphNodeID addNode (std::unique_ptr processor); @@ -114,7 +137,7 @@ class YUP_API AudioGraphModel final /** Returns a snapshot of the current model connections. */ std::vector getConnections() const; - /** Removes all graph nodes and connections. */ + /** Removes all processor nodes and connections. */ void clear(); //============================================================================== @@ -130,7 +153,7 @@ class YUP_API AudioGraphModel final /** 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 model. */ + /** Returns the identifiers of all processor nodes currently in the model. */ std::vector getNodeIDs() const; /** Sets the factory used to recreate processor nodes during state loading. */ @@ -159,6 +182,7 @@ class YUP_API AudioGraphModel final private: struct ModelNode { + NodeKind kind = NodeKind::processor; AudioGraphNodeID id; std::shared_ptr processor; AudioGraphNodeProperties properties; @@ -178,6 +202,7 @@ class YUP_API AudioGraphModel final ResultValue> createProcessorForSavedNode (const AudioGraphNodeProperties& properties); Result validateConnectionLocked (const AudioGraphConnection& connection) const; + void resetBoundaryNodes(); void markTopologyChanged(); void markMetadataChanged(); void rebuildDataTree(); diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp index 1c3554bfa..3e0f42338 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp @@ -249,7 +249,8 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener nodesSnapshot.reserve (snapshot.nodes.size()); for (const auto& node : snapshot.nodes) - nodesSnapshot.push_back ({ node.id, node.processor, node.properties }); + if (node.kind == AudioGraphModel::NodeKind::processor) + nodesSnapshot.push_back ({ node.id, node.processor, node.properties }); connectionsSnapshot = snapshot.connections; std::vector> newlyPreparedNodes; @@ -323,12 +324,16 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener for (int i = 0; i < static_cast (snapshot.nodes.size()); ++i) { const auto& node = snapshot.nodes[static_cast (i)]; + if (node.kind != AudioGraphModel::NodeKind::processor) + continue; + + const auto nodeIndex = static_cast (nodesSnapshot.size()); nodesSnapshot.push_back ({ node.id, node.processor, node.properties }); if (node.processor == nullptr) return Result::fail ("Audio graph contains an empty node"); - if (! nodeIndexByID.emplace (node.id.getRawID(), i).second) + if (! nodeIndexByID.emplace (node.id.getRawID(), nodeIndex).second) return Result::fail ("Audio graph contains duplicate node IDs"); } @@ -367,6 +372,9 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener for (const auto& node : snapshot.nodes) { + if (node.kind != AudioGraphModel::NodeKind::processor) + continue; + const auto stateIterator = preparedNodes.find (node.id.getRawID()); const bool isPrepared = stateIterator != preparedNodes.end() && stateIterator->second.prepared diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp index 8f8a24241..f2bcf6e64 100644 --- a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp +++ b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.cpp @@ -36,13 +36,93 @@ AudioGraphNodeView* findNodeViewForEventSource (Component* source) noexcept } } // namespace +//============================================================================== +struct AudioGraphComponent::ConnectionsAction : public UndoableAction +{ + ConnectionsAction (AudioGraphComponent& componentToUse, + std::vector connectionsToUse, + bool shouldBeConnectedAfterRedoToUse) + : component (&componentToUse) + , connections (std::move (connectionsToUse)) + , shouldBeConnectedAfterRedo (shouldBeConnectedAfterRedoToUse) + { + } + + bool isValid() const override + { + return component.get() != nullptr && ! connections.empty(); + } + + bool perform (UndoableActionState stateToPerform) override + { + if (auto* graphComponent = component.get()) + { + const auto shouldBeConnected = stateToPerform == UndoableActionState::Redo + ? shouldBeConnectedAfterRedo + : ! shouldBeConnectedAfterRedo; + + for (const auto& connection : connections) + if (! graphComponent->performConnectionEdit (connection, shouldBeConnected)) + return false; + + return true; + } + + return false; + } + +private: + WeakReference component; + std::vector connections; + bool shouldBeConnectedAfterRedo = false; +}; + +struct AudioGraphComponent::NodeMoveAction : public UndoableAction +{ + NodeMoveAction (AudioGraphComponent& componentToUse, + AudioGraphNodeID nodeIDToUse, + Point oldCanvasPosToUse, + Point newCanvasPosToUse) + : component (&componentToUse) + , nodeID (nodeIDToUse) + , oldCanvasPos (oldCanvasPosToUse) + , newCanvasPos (newCanvasPosToUse) + { + } + + bool isValid() const override + { + return component.get() != nullptr && nodeID.isValid(); + } + + bool perform (UndoableActionState stateToPerform) override + { + if (auto* graphComponent = component.get()) + { + if (stateToPerform == UndoableActionState::Redo) + return graphComponent->performNodeMove (nodeID, oldCanvasPos, newCanvasPos); + + return graphComponent->performNodeMove (nodeID, newCanvasPos, oldCanvasPos); + } + + return false; + } + +private: + WeakReference component; + AudioGraphNodeID nodeID; + Point oldCanvasPos; + Point newCanvasPos; +}; + //============================================================================== const Identifier AudioGraphComponent::Style::backgroundColorId ("audioGraphBackground"); const Identifier AudioGraphComponent::Style::gridColorId ("audioGraphGrid"); //============================================================================== -AudioGraphComponent::AudioGraphComponent (std::shared_ptr modelIn) +AudioGraphComponent::AudioGraphComponent (std::shared_ptr modelIn, UndoManager* undoManagerIn) : model (std::move (modelIn)) + , undoManager (undoManagerIn) { setOpaque (true); setWantsKeyboardFocus (true); @@ -50,8 +130,8 @@ AudioGraphComponent::AudioGraphComponent (std::shared_ptr model enableRenderingUnclipped (true); } -AudioGraphComponent::AudioGraphComponent (std::shared_ptr graphIn) - : AudioGraphComponent (graphIn != nullptr ? graphIn->getModel() : nullptr) +AudioGraphComponent::AudioGraphComponent (std::shared_ptr graphIn, UndoManager* undoManagerIn) + : AudioGraphComponent (graphIn != nullptr ? graphIn->getModel() : nullptr, undoManagerIn) { graph = std::move (graphIn); } @@ -87,6 +167,12 @@ void AudioGraphComponent::setGraphInputView (std::unique_ptr if (view == nullptr) return; + const auto nodeID = model != nullptr ? AudioGraphModel::getGraphInputNodeID() + : AudioGraphNodeID::invalid(); + + if (model != nullptr) + model->setNodePosition (nodeID, initialCanvasPosition.getX(), initialCanvasPosition.getY()); + auto* item = findNodeItemByKind (NodeItem::Kind::graphInput); if (item != nullptr) { @@ -97,11 +183,12 @@ void AudioGraphComponent::setGraphInputView (std::unique_ptr } item->view = std::move (view); + item->nodeID = nodeID; item->canvasPosition = initialCanvasPosition; } else { - nodes.push_back ({ AudioGraphNodeID::invalid(), std::move (view), initialCanvasPosition, NodeItem::Kind::graphInput }); + nodes.push_back ({ nodeID, std::move (view), initialCanvasPosition, NodeItem::Kind::graphInput }); item = &nodes.back(); } @@ -117,6 +204,12 @@ void AudioGraphComponent::setGraphOutputView (std::unique_ptrsetNodePosition (nodeID, initialCanvasPosition.getX(), initialCanvasPosition.getY()); + auto* item = findNodeItemByKind (NodeItem::Kind::graphOutput); if (item != nullptr) { @@ -127,11 +220,12 @@ void AudioGraphComponent::setGraphOutputView (std::unique_ptrview = std::move (view); + item->nodeID = nodeID; item->canvasPosition = initialCanvasPosition; } else { - nodes.push_back ({ AudioGraphNodeID::invalid(), std::move (view), initialCanvasPosition, NodeItem::Kind::graphOutput }); + nodes.push_back ({ nodeID, std::move (view), initialCanvasPosition, NodeItem::Kind::graphOutput }); item = &nodes.back(); } @@ -172,6 +266,11 @@ AudioGraphNodeView* AudioGraphComponent::getNodeView (AudioGraphNodeID nodeID) c return iterator != nodes.end() ? iterator->view.get() : nullptr; } +void AudioGraphComponent::setUndoManager (UndoManager* newUndoManager) noexcept +{ + undoManager = newUndoManager; +} + //============================================================================== void AudioGraphComponent::setZoom (float newZoom) { @@ -374,7 +473,7 @@ void AudioGraphComponent::mouseDown (const MouseEvent& event) for (const auto& node : nodes) { - if (node.view == nullptr || ! node.nodeID.isValid()) + if (node.kind != NodeItem::Kind::processor || node.view == nullptr || ! node.nodeID.isValid()) continue; const auto localPos = node.view->getLocalPoint (this, screenPos); @@ -512,28 +611,37 @@ void AudioGraphComponent::mouseUp (const MouseEvent& event) { if (isPositiveAndBelow (draggedNodeIndex, static_cast (nodes.size()))) { - const auto& item = nodes[static_cast (draggedNodeIndex)]; + auto& draggedItem = nodes[static_cast (draggedNodeIndex)]; + bool accepted = true; - if (item.kind == NodeItem::Kind::processor) + if (draggedItem.canvasPosition != dragStartNodePosition) { - auto& draggedItem = nodes[static_cast (draggedNodeIndex)]; - bool accepted = true; - - if (onNodeMoveRequested != nullptr) - accepted = onNodeMoveRequested (draggedItem.nodeID, dragStartNodePosition, draggedItem.canvasPosition); - - if (! accepted) + if (undoManager != nullptr) + { + accepted = performUndoableAction (new NodeMoveAction (*this, + draggedItem.nodeID, + dragStartNodePosition, + draggedItem.canvasPosition), + "Move Audio Graph Node"); + } + else { - draggedItem.canvasPosition = dragStartNodePosition; - updateNodeBounds(); - repaint(); - interaction = Interaction::idle; - draggedNodeIndex = -1; - break; + accepted = performNodeMove (draggedItem.nodeID, dragStartNodePosition, draggedItem.canvasPosition); } + } - listeners.call (&Listener::nodeViewMoved, draggedItem.nodeID, draggedItem.canvasPosition); + if (! accepted) + { + draggedItem.canvasPosition = dragStartNodePosition; + updateNodeBounds(); + repaint(); + interaction = Interaction::idle; + draggedNodeIndex = -1; + break; } + + if (draggedItem.kind == NodeItem::Kind::processor) + listeners.call (&Listener::nodeViewMoved, draggedItem.nodeID, draggedItem.canvasPosition); } interaction = Interaction::idle; @@ -561,7 +669,7 @@ void AudioGraphComponent::mouseDoubleClick (const MouseEvent& event) for (const auto& node : nodes) { - if (node.view == nullptr || ! node.nodeID.isValid()) + if (node.kind != NodeItem::Kind::processor || node.view == nullptr || ! node.nodeID.isValid()) continue; const auto localPos = node.view->getLocalPoint (this, screenPos); @@ -594,6 +702,28 @@ void AudioGraphComponent::mouseWheel (const MouseEvent& event, const MouseWheelD void AudioGraphComponent::keyDown (const KeyPress& keys, const Point&) { + const auto modifiers = keys.getModifiers(); + const bool commandDown = modifiers.isCommandDown() || modifiers.isControlDown(); + + if (undoManager != nullptr && commandDown) + { + if (keys.getKey() == KeyPress::textZKey) + { + if (modifiers.isShiftDown()) + undoManager->redo(); + else + undoManager->undo(); + + return; + } + + if (keys.getKey() == KeyPress::textYKey) + { + undoManager->redo(); + return; + } + } + if (keys.getKey() == KeyPress::spaceKey) { spacebarDown = true; @@ -891,8 +1021,16 @@ bool AudioGraphComponent::tryConnect (const AudioGraphEndpoint& first, const Aud return false; } - if (onConnectionRequested == nullptr || ! onConnectionRequested (connection)) - return false; + if (undoManager != nullptr && onConnectionRemovalRequested != nullptr) + { + if (! performUndoableAction (new ConnectionsAction (*this, { connection }, true), "Add Audio Graph Connection")) + return false; + } + else + { + if (! performConnectionEdit (connection, true)) + return false; + } listeners.call (&Listener::connectionAdded, connection); @@ -902,9 +1040,20 @@ bool AudioGraphComponent::tryConnect (const AudioGraphEndpoint& first, const Aud bool AudioGraphComponent::requestConnectionRemoval (const AudioGraphConnection& connection) { - if (model == nullptr || onConnectionRemovalRequested == nullptr || ! onConnectionRemovalRequested (connection)) + if (model == nullptr) return false; + if (undoManager != nullptr && onConnectionRequested != nullptr) + { + if (! performUndoableAction (new ConnectionsAction (*this, { connection }, false), "Remove Audio Graph Connection")) + return false; + } + else + { + if (! performConnectionEdit (connection, false)) + return false; + } + listeners.call (&Listener::connectionRemoved, connection); repaint(); @@ -926,7 +1075,7 @@ void AudioGraphComponent::removeConnectionsForEndpoint (const AudioGraphEndpoint if (removedConnections.empty()) return; - if (! onEndpointConnectionsRemovalRequested (endpoint)) + if (! performEndpointConnectionsRemoval (endpoint, removedConnections)) return; for (const auto& connection : removedConnections) @@ -935,6 +1084,72 @@ void AudioGraphComponent::removeConnectionsForEndpoint (const AudioGraphEndpoint repaint(); } +bool AudioGraphComponent::performUndoableAction (UndoableAction::Ptr action, StringRef transactionName) +{ + if (undoManager == nullptr || action == nullptr) + return false; + + UndoManager::ScopedTransaction transaction (*undoManager, transactionName); + return undoManager->perform (std::move (action)); +} + +bool AudioGraphComponent::performConnectionEdit (const AudioGraphConnection& connection, bool shouldBeConnected) +{ + if (shouldBeConnected) + { + if (onConnectionRequested == nullptr || ! onConnectionRequested (connection)) + return false; + } + else + { + if (onConnectionRemovalRequested == nullptr || ! onConnectionRemovalRequested (connection)) + return false; + } + + repaint(); + return true; +} + +bool AudioGraphComponent::performEndpointConnectionsRemoval (const AudioGraphEndpoint& endpoint, + const std::vector& connections) +{ + if (undoManager != nullptr && onConnectionRequested != nullptr && onConnectionRemovalRequested != nullptr) + return performUndoableAction (new ConnectionsAction (*this, connections, false), "Remove Audio Graph Connections"); + + return onEndpointConnectionsRemovalRequested != nullptr && onEndpointConnectionsRemovalRequested (endpoint); +} + +bool AudioGraphComponent::performNodeMove (AudioGraphNodeID nodeID, Point oldCanvasPos, Point newCanvasPos) +{ + if (! nodeID.isValid()) + return false; + + const auto iterator = std::find_if (nodes.begin(), nodes.end(), [nodeID] (const NodeItem& item) + { + return item.nodeID == nodeID; + }); + + if (iterator == nodes.end()) + return false; + + if (iterator->kind == NodeItem::Kind::processor && onNodeMoveRequested != nullptr) + { + if (! onNodeMoveRequested (nodeID, oldCanvasPos, newCanvasPos)) + return false; + } + else if (model != nullptr) + { + if (! model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY())) + return false; + } + + iterator->canvasPosition = newCanvasPos; + updateNodeBounds(); + repaint(); + + return true; +} + bool AudioGraphComponent::isCompatiblePair (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept { return (first.isSource() && second.isDestination()) || (second.isSource() && first.isDestination()); diff --git a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h index 2fdf084b1..0cd08dd5d 100644 --- a/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h +++ b/modules/yup_audio_gui/graph/yup_AudioGraphComponent.h @@ -46,11 +46,23 @@ class YUP_API AudioGraphComponent : public Component }; //============================================================================== - /** Creates an editor for a graph model. */ - explicit AudioGraphComponent (std::shared_ptr model); + /** Creates an editor for a graph model. - /** Creates an editor for a processor's model. */ - explicit AudioGraphComponent (std::shared_ptr graph); + When undoManager is not null, committed graph edit gestures are added to + the undo history and the component handles standard undo/redo keyboard + shortcuts while focused. + */ + explicit AudioGraphComponent (std::shared_ptr model, + UndoManager* undoManager = nullptr); + + /** Creates an editor for a processor's model. + + When undoManager is not null, committed graph edit gestures are added to + the undo history and the component handles standard undo/redo keyboard + shortcuts while focused. + */ + explicit AudioGraphComponent (std::shared_ptr graph, + UndoManager* undoManager = nullptr); /** Destructor. */ ~AudioGraphComponent() override; @@ -75,6 +87,20 @@ class YUP_API AudioGraphComponent : public Component /** Returns the view for a node, or nullptr. */ AudioGraphNodeView* getNodeView (AudioGraphNodeID nodeID) const noexcept; + //============================================================================== + /** Sets the optional undo manager used for graph edit gestures. + + The component does not own the undo manager. Passing nullptr disables + automatic undo action creation; edit callbacks continue to be invoked + directly. Undoable connection edits require both connection add and + connection removal callbacks because redo and undo use the same request + path as user gestures. + */ + void setUndoManager (UndoManager* newUndoManager) noexcept; + + /** Returns the undo manager used by this component, or nullptr. */ + UndoManager* getUndoManager() const noexcept { return undoManager; } + //============================================================================== /** Sets the zoom factor, clamped to [getMinZoom(), getMaxZoom()]. */ void setZoom (float zoom); @@ -217,6 +243,9 @@ class YUP_API AudioGraphComponent : public Component void keyUp (const KeyPress& keys, const Point& position) override; private: + struct ConnectionsAction; + struct NodeMoveAction; + struct NodeItem { enum class Kind @@ -264,6 +293,12 @@ class YUP_API AudioGraphComponent : public Component bool requestConnectionRemoval (const AudioGraphConnection& connection); void removeConnectionsForEndpoint (const AudioGraphEndpoint& endpoint); + bool performUndoableAction (UndoableAction::Ptr action, StringRef transactionName); + bool performConnectionEdit (const AudioGraphConnection& connection, bool shouldBeConnected); + bool performEndpointConnectionsRemoval (const AudioGraphEndpoint& endpoint, + const std::vector& connections); + bool performNodeMove (AudioGraphNodeID nodeID, Point oldCanvasPos, Point newCanvasPos); + bool isCompatiblePair (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept; AudioGraphConnection makeConnection (const AudioGraphEndpoint& first, const AudioGraphEndpoint& second) const noexcept; @@ -272,6 +307,7 @@ class YUP_API AudioGraphComponent : public Component std::shared_ptr model; std::shared_ptr graph; + UndoManager* undoManager = nullptr; std::vector nodes; ListenerList listeners; @@ -299,6 +335,7 @@ class YUP_API AudioGraphComponent : public Component Point pendingWireEnd; YUP_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioGraphComponent) + YUP_DECLARE_WEAK_REFERENCEABLE (AudioGraphComponent) }; } // namespace yup diff --git a/tests/yup_audio_gui/yup_AudioGraphComponent.cpp b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp index 155083edd..dc92e93ca 100644 --- a/tests/yup_audio_gui/yup_AudioGraphComponent.cpp +++ b/tests/yup_audio_gui/yup_AudioGraphComponent.cpp @@ -771,6 +771,65 @@ TEST_F (AudioGraphComponentTests, DraggingCompatibleEndpointsAddsConnectionAndCl EXPECT_FALSE (component->getPendingWireEndpoint().has_value()); } +TEST_F (AudioGraphComponentTests, UndoManagerRestoresConnectionAddedByGesture) +{ + UndoManager undoManager; + component->setUndoManager (&undoManager); + + 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) }; + + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (connection.source))); + component->mouseDown (mouseEventForComponent (MouseEvent::leftButton, component->getEndpointScreenPosition (connection.destination))); + + ASSERT_EQ (1u, model->getConnections().size()); + + component->keyDown (KeyPress (KeyPress::textZKey, KeyModifiers (KeyModifiers::commandMask)), Point()); + EXPECT_TRUE (model->getConnections().empty()); + + component->keyDown (KeyPress (KeyPress::textZKey, KeyModifiers (KeyModifiers::commandMask | KeyModifiers::shiftMask)), Point()); + const auto connections = model->getConnections(); + ASSERT_EQ (1u, connections.size()); + EXPECT_EQ (connection, connections.front()); +} + +TEST_F (AudioGraphComponentTests, UndoManagerRestoresConnectionRemovedByGesture) +{ + UndoManager undoManager; + component->setUndoManager (&undoManager); + + 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 (model->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)); + + ASSERT_TRUE (model->getConnections().empty()); + + EXPECT_TRUE (undoManager.undo()); + ASSERT_EQ (1u, model->getConnections().size()); + + EXPECT_TRUE (undoManager.redo()); + EXPECT_TRUE (model->getConnections().empty()); +} + TEST_F (AudioGraphComponentTests, IncompatibleEndpointPairKeepsNewEndpointArmedAndDoesNotConnect) { const auto firstNodeID = addProcessorNode(); @@ -865,6 +924,42 @@ TEST_F (AudioGraphComponentTests, RightClickEndpointRemovesMultipleConnectionsFo component->removeListener (&listener); } +TEST_F (AudioGraphComponentTests, UndoManagerRestoresMultipleEndpointConnectionsRemovedByGesture) +{ + UndoManager undoManager; + component->setUndoManager (&undoManager); + + 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 (model->addConnection (firstConnection).wasOk()); + ASSERT_TRUE (model->addConnection (secondConnection).wasOk()); + ASSERT_TRUE (graph->commitChanges().wasOk()); + + component->mouseDown (mouseEventForComponent (MouseEvent::rightButton, component->getEndpointScreenPosition (firstConnection.source))); + + ASSERT_TRUE (model->getConnections().empty()); + + EXPECT_TRUE (undoManager.undo()); + auto connections = model->getConnections(); + ASSERT_EQ (2u, connections.size()); + EXPECT_EQ (firstConnection, connections[0]); + EXPECT_EQ (secondConnection, connections[1]); + + EXPECT_TRUE (undoManager.redo()); + EXPECT_TRUE (model->getConnections().empty()); +} + TEST_F (AudioGraphComponentTests, RightClickConnectionRemovesSingleConnection) { const auto nodeID = addProcessorNode(); @@ -908,6 +1003,33 @@ TEST_F (AudioGraphComponentTests, DraggingNodeUpdatesCanvasPositionAndNotifiesLi component->removeListener (&listener); } +TEST_F (AudioGraphComponentTests, UndoManagerRestoresNodeMove) +{ + UndoManager undoManager; + component->setUndoManager (&undoManager); + + const auto nodeID = addProcessorNode(); + auto* view = addNodeView (nodeID, { 100.0f, 50.0f }); + + 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())); + + expectPointNear (view->getBounds().getPosition(), component->canvasToScreen ({ 125.0f, 85.0f })); + + EXPECT_TRUE (undoManager.undo()); + expectPointNear (view->getBounds().getPosition(), component->canvasToScreen ({ 100.0f, 50.0f })); + + auto properties = model->getNodeProperties (nodeID); + ASSERT_TRUE (properties.has_value()); + EXPECT_FLOAT_EQ (100.0f, properties->positionX); + EXPECT_FLOAT_EQ (50.0f, properties->positionY); + + EXPECT_TRUE (undoManager.redo()); + expectPointNear (view->getBounds().getPosition(), component->canvasToScreen ({ 125.0f, 85.0f })); +} + TEST_F (AudioGraphComponentTests, DraggingGraphBoundaryViewDoesNotNotifyNodeMoveListener) { auto graphInput = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); @@ -925,5 +1047,43 @@ TEST_F (AudioGraphComponentTests, DraggingGraphBoundaryViewDoesNotNotifyNodeMove EXPECT_EQ (0, listener.nodeMoveCount); expectPointNear (graphInputView->getBounds().getPosition(), component->canvasToScreen ({ -95.0f, 85.0f })); + auto properties = model->getNodeProperties (AudioGraphModel::getGraphInputNodeID()); + ASSERT_TRUE (properties.has_value()); + EXPECT_FLOAT_EQ (-95.0f, properties->positionX); + EXPECT_FLOAT_EQ (85.0f, properties->positionY); + component->removeListener (&listener); } + +TEST_F (AudioGraphComponentTests, UndoManagerRestoresGraphBoundaryNodeMove) +{ + UndoManager undoManager; + component->setUndoManager (&undoManager); + + auto graphInput = std::make_unique (AudioGraphNodeID::invalid(), "Graph Input", 0, 1); + auto* graphInputView = graphInput.get(); + component->setGraphInputView (std::move (graphInput), { -120.0f, 50.0f }); + + 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())); + + expectPointNear (graphInputView->getBounds().getPosition(), component->canvasToScreen ({ -95.0f, 85.0f })); + + auto properties = model->getNodeProperties (AudioGraphModel::getGraphInputNodeID()); + ASSERT_TRUE (properties.has_value()); + EXPECT_FLOAT_EQ (-95.0f, properties->positionX); + EXPECT_FLOAT_EQ (85.0f, properties->positionY); + + EXPECT_TRUE (undoManager.undo()); + expectPointNear (graphInputView->getBounds().getPosition(), component->canvasToScreen ({ -120.0f, 50.0f })); + + properties = model->getNodeProperties (AudioGraphModel::getGraphInputNodeID()); + ASSERT_TRUE (properties.has_value()); + EXPECT_FLOAT_EQ (-120.0f, properties->positionX); + EXPECT_FLOAT_EQ (50.0f, properties->positionY); + + EXPECT_TRUE (undoManager.redo()); + expectPointNear (graphInputView->getBounds().getPosition(), component->canvasToScreen ({ -95.0f, 85.0f })); +} From b25a2a7e93a1ef01ece7088e46712d11ea27cf26 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 00:50:36 +0200 Subject: [PATCH 10/14] Nice refactorings --- examples/audiograph/source/AudioGraphApp.h | 430 +++--------------- .../source/ui/AudioGraphEditorPanel.h | 267 +++++++++-- .../graph/yup_AudioGraphModel.cpp | 74 ++- 3 files changed, 371 insertions(+), 400 deletions(-) diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h index 40917e9e5..c2e0c5b73 100644 --- a/examples/audiograph/source/AudioGraphApp.h +++ b/examples/audiograph/source/AudioGraphApp.h @@ -23,7 +23,6 @@ #include #include -#include #include #include @@ -39,7 +38,6 @@ class AudioGraphApp final : public yup::Component , public yup::AudioIODeviceCallback - , public yup::AudioGraphComponent::Listener { public: AudioGraphApp() @@ -68,30 +66,8 @@ class AudioGraphApp final model->setNodeFactory (nodeRegistry.makeProcessorFactory()); - graphComponent = std::make_unique (graph, &undoManager); - - graphComponent->onConnectionRequested = [this] (const yup::AudioGraphConnection& connection) - { - return addConnection (connection); - }; - - graphComponent->onConnectionRemovalRequested = [this] (const yup::AudioGraphConnection& connection) - { - return removeConnection (connection); - }; - - graphComponent->onEndpointConnectionsRemovalRequested = [this] (const yup::AudioGraphEndpoint& endpoint) - { - return removeConnectionsForEndpoint (endpoint); - }; - - graphComponent->onNodeMoveRequested = [this] (yup::AudioGraphNodeID nodeID, yup::Point oldCanvasPos, yup::Point newCanvasPos) - { - return moveNode (nodeID, oldCanvasPos, newCanvasPos); - }; - - graphComponent->addListener (this); - addAndMakeVisible (*graphComponent); + editorPanel = createMainPanel(); + addAndMakeVisible (*editorPanel); setupToolbar(); resetToEmptyGraph(); @@ -131,7 +107,7 @@ class AudioGraphApp final scanButton.setBounds (toolbar.removeFromLeft (100).reduced (4, 4)); statusLabel.setBounds (toolbar.reduced (4, 4)); - graphComponent->setBounds (bounds); + editorPanel->setBounds (bounds); } void paint (yup::Graphics& g) override @@ -207,23 +183,6 @@ class AudioGraphApp final graph->releaseResources(); } - //============================================================================== - // AudioGraphComponent::Listener - void nodeContextMenu (yup::AudioGraphNodeID nodeID, yup::Point) override - { - removeNode (nodeID); - } - - void nodeDoubleClicked (yup::AudioGraphNodeID nodeID) override - { - openNodeEditor (graph, nodeID); - } - - void canvasContextMenu (yup::Point canvasPos) override - { - showAddNodeMenu (canvasPos); - } - private: //============================================================================== @@ -262,63 +221,20 @@ class AudioGraphApp final addAndMakeVisible (statusLabel); } - void clearNodeViews() - { - for (auto& [nodeID, _] : loadedNodes) - graphComponent->removeNodeView (nodeID); - - loadedNodes.clear(); - } - - void reloadGraphViewsFromModel() - { - clearNodeViews(); - - for (auto nodeID : model->getNodeIDs()) - { - auto props = model->getNodeProperties (nodeID); - if (! props.has_value()) - continue; - - auto* proc = model->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); - - if (view != nullptr) - { - graphComponent->addNodeView (nodeID, std::move (view), { props->positionX, props->positionY }); - loadedNodes[nodeID] = props->identifier; - } - } - } - - void reloadGraphBoundaryViewsFromModel() - { - const auto input = model->getNodeProperties (yup::AudioGraphModel::getGraphInputNodeID()) - .value_or (yup::AudioGraphNodeProperties {}); - graphComponent->setGraphInputView (std::make_unique(), { input.positionX, input.positionY }); - - const auto output = model->getNodeProperties (yup::AudioGraphModel::getGraphOutputNodeID()) - .value_or (yup::AudioGraphNodeProperties {}); - graphComponent->setGraphOutputView (std::make_unique(), { output.positionX, output.positionY }); - } - - //============================================================================== - void resetToEmptyGraph() { closePluginEditor(); closeAllSubgraphEditors(); - clearNodeViews(); + editorPanel->clearNodeViews(); model->clear(); model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); graph->commitChanges(); - undoManager.clear(); + editorPanel->clearUndoHistory(); currentFilePath = yup::File(); - reloadGraphBoundaryViewsFromModel(); - graphComponent->zoomToFitNodes(); + editorPanel->reloadViews(); } //============================================================================== @@ -402,7 +318,7 @@ class AudioGraphApp final closePluginEditor(); closeAllSubgraphEditors(); - clearNodeViews(); + editorPanel->clearNodeViews(); model->clear(); @@ -417,261 +333,16 @@ class AudioGraphApp final model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); graph->commitChanges(); - undoManager.clear(); + editorPanel->clearUndoHistory(); currentFilePath = file; - reloadGraphViewsFromModel(); - reloadGraphBoundaryViewsFromModel(); - graphComponent->zoomToFitNodes(); + editorPanel->reloadViews(); 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(); - - auto internalNodes = nodeRegistry.getInternalNodeIdentifiers(); - for (const auto& identifier : internalNodes) - { - const auto displayName = NodeRegistry::identifierToDisplayName (identifier); - internalSubMenu->addItem (displayName, static_cast (internalSubMenu->getNumItems() + 1)); - } - - auto subgraphSubMenu = yup::PopupMenu::create(); - subgraphSubMenu->addItem ("Mono Subgraph", 10); - subgraphSubMenu->addItem ("Stereo Subgraph", 11); - subgraphSubMenu->addItem ("Mono Subgraph + MIDI", 12); - subgraphSubMenu->addItem ("Stereo Subgraph + MIDI", 13); - - activeMenu = yup::PopupMenu::create (options); - activeMenu->addSubMenu ("Internal Nodes", internalSubMenu); - activeMenu->addSubMenu ("Subgraphs", subgraphSubMenu); - -#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, internalNodes = std::move (internalNodes)] (int selectedID) - { - if (selectedID <= 0) - return; - - if (selectedID >= 1 && selectedID <= static_cast (internalNodes.size())) - { - addInternalNode (internalNodes[selectedID - 1], pendingMenuCanvasPos); - } - else if (selectedID >= 10 && selectedID <= 13) - { - const auto preset = selectedID == 10 ? SubgraphConfig::mono - : selectedID == 11 ? SubgraphConfig::stereo - : selectedID == 12 ? SubgraphConfig::monoMidi - : SubgraphConfig::stereoMidi; - const auto config = SubgraphConfig::fromPreset (preset); - addInternalNode (NodeRegistry::subgraphIdentifier, - pendingMenuCanvasPos, - SubgraphConfig::toCreationData (config)); - } -#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::MemoryBlock creationData = {}) - { - yup::AudioGraphNodeProperties props; - props.identifier = identifier; - props.name = identifier == NodeRegistry::subgraphIdentifier - ? SubgraphConfig::fromCreationData (creationData).getDisplayName() - : NodeRegistry::identifierToDisplayName (identifier); - props.positionX = canvasPos.getX(); - props.positionY = canvasPos.getY(); - props.creationData = std::move (creationData); - - auto processorResult = nodeRegistry.makeProcessorFactory() (props); - - if (processorResult.failed()) - { - statusLabel.setText ("Failed: " + processorResult.getErrorMessage(), yup::dontSendNotification); - return; - } - - const auto nodeID = model->addNode (std::move (processorResult).getValue(), props); - if (! nodeID.isValid()) - { - statusLabel.setText ("Failed to add node to graph.", yup::dontSendNotification); - return; - } - - graph->commitChanges(); - - auto* rawProc = model->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, identifier, rawProc, graph.get()); - 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 = model->addNode (std::move (result).getValue(), props); - if (! nodeID.isValid()) - { - statusLabel.setText ("Failed to add plugin to graph.", yup::dontSendNotification); - return; - } - - graph->commitChanges(); - - auto* rawProc = model->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, props.identifier, rawProc, graph.get()); - 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 (model->getNodeProcessor (nodeID) == nullptr) - return; - - if (activePluginEditorGraph == graph.get() && activePluginEditorNodeID == nodeID) - closePluginEditor(); - - closeSubgraphEditorForNode (graph, nodeID); - - graphComponent->removeNodeView (nodeID); - model->removeNode (nodeID); - graph->commitChanges(); - loadedNodes.erase (nodeID); - } - - bool addConnection (const yup::AudioGraphConnection& connection) - { - if (model->addConnection (connection).failed()) - return false; - - if (graph->commitChanges().wasOk()) - return true; - - model->removeConnection (connection); - graph->commitChanges(); - return false; - } - - bool removeConnection (const yup::AudioGraphConnection& connection) - { - if (! model->removeConnection (connection)) - return false; - - if (graph->commitChanges().wasOk()) - return true; - - model->addConnection (connection); - graph->commitChanges(); - return false; - } - - bool removeConnectionsForEndpoint (const yup::AudioGraphEndpoint& endpoint) - { - const auto connections = model->getConnections(); - std::vector removedConnections; - - for (const auto& connection : connections) - { - if (connection.source == endpoint || connection.destination == endpoint) - { - if (model->removeConnection (connection)) - removedConnections.push_back (connection); - } - } - - if (removedConnections.empty()) - return false; - - if (graph->commitChanges().wasOk()) - return true; - - for (const auto& connection : removedConnections) - model->addConnection (connection); - - graph->commitChanges(); - return false; - } - - bool moveNode (yup::AudioGraphNodeID nodeID, yup::Point, yup::Point newCanvasPos) - { - return model->setNodePosition (nodeID, newCanvasPos.getX(), newCanvasPos.getY()); - } - - //============================================================================== - void openNodeEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) { if (ownerGraph == nullptr) @@ -742,16 +413,43 @@ class AudioGraphApp final std::unique_ptr window; }; + std::unique_ptr createMainPanel() + { + AudioGraphEditorPanel::EndpointViews endpointViews; + endpointViews.createInputView = [] + { + return std::make_unique(); + }; + endpointViews.createOutputView = [] + { + return std::make_unique(); + }; + + auto panel = std::make_unique (graph, nodeRegistry, std::move (endpointViews)); + configurePanelCallbacks (*panel); + return panel; + } + std::unique_ptr createSubgraphPanel (std::shared_ptr childGraph) { auto panel = std::make_unique (std::move (childGraph), nodeRegistry, "parent graph"); + configurePanelCallbacks (*panel); + return panel; + } + + void configurePanelCallbacks (AudioGraphEditorPanel& panel) + { + panel.onStatusMessage = [this] (const yup::String& message) + { + statusLabel.setText (message, yup::dontSendNotification); + }; - panel->onNodeDoubleClicked = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) + panel.onNodeDoubleClicked = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) { openNodeEditor (editor.getGraph(), nodeID); }; - panel->onNodeWillBeRemoved = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) + panel.onNodeWillBeRemoved = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) { if (activePluginEditorGraph == editor.getGraph().get() && activePluginEditorNodeID == nodeID) closePluginEditor(); @@ -760,19 +458,21 @@ class AudioGraphApp final }; #if YUP_DESKTOP - panel->getDiscoveredPlugins = [this]() -> const std::vector& + panel.getDiscoveredPlugins = [this]() -> const std::vector& { return nodeRegistry.getDiscoveredPlugins(); }; - panel->onPluginSelected = [this] (AudioGraphEditorPanel& editor, - const yup::AudioPluginDescription& desc, - yup::Point canvasPos) + panel.getPluginMenuItemText = [] (const yup::AudioPluginDescription& desc) + { + return desc.name + " [" + formatTypeToString (desc.formatType) + "]"; + }; + panel.onPluginSelected = [this] (AudioGraphEditorPanel& editor, + const yup::AudioPluginDescription& desc, + yup::Point canvasPos) { addPluginNodeToPanel (editor, desc, canvasPos); }; #endif - - return panel; } void openSubgraphEditor (std::shared_ptr ownerGraph, @@ -875,26 +575,17 @@ class AudioGraphApp final void refreshNodeViewsForGraph (const yup::AudioGraphProcessor* graphToRefresh, yup::AudioGraphNodeID nodeID) { if (graphToRefresh == graph.get()) - { - graphComponent->removeNodeView (nodeID); - loadedNodes.erase (nodeID); + editorPanel->refreshNodeView (nodeID); - if (auto props = model->getNodeProperties (nodeID)) - { - auto* proc = model->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, props->identifier, proc, graph.get()); + for (auto& record : subgraphEditorWindows) + { + if (record.window == nullptr) + continue; - if (view != nullptr) - { - graphComponent->addNodeView (nodeID, std::move (view), { props->positionX, props->positionY }); - loadedNodes[nodeID] = props->identifier; - } - } + auto* panel = record.window->getEditorPanel(); + if (panel != nullptr && panel->getGraph().get() == graphToRefresh) + panel->refreshNodeView (nodeID); } - - for (auto& record : subgraphEditorWindows) - if (record.ownerGraphRaw == graphToRefresh && record.window != nullptr && record.window->getEditorPanel() != nullptr) - record.window->getEditorPanel()->refreshNodeView (nodeID); } void closeSubgraphEditorForNode (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) @@ -955,7 +646,10 @@ class AudioGraphApp final { 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()) @@ -971,7 +665,8 @@ class AudioGraphApp final props.positionY = canvasPos.getY(); props.creationData = NodeRegistry::descriptionToCreationData (desc); - editor.addProcessorNode (std::move (result).getValue(), std::move (props), canvasPos); + if (editor.addProcessorNode (std::move (result).getValue(), std::move (props), canvasPos)) + statusLabel.setText ("Loaded: " + desc.name, yup::dontSendNotification); } #endif @@ -1044,10 +739,8 @@ class AudioGraphApp final yup::AudioDeviceManager deviceManager; std::shared_ptr graph; std::shared_ptr model; - yup::UndoManager undoManager; - std::unique_ptr graphComponent; NodeRegistry nodeRegistry; - std::map loadedNodes; + std::unique_ptr editorPanel; yup::File currentFilePath; yup::FileChooser::Ptr fileChooser; @@ -1065,9 +758,6 @@ class AudioGraphApp final 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 }; diff --git a/examples/audiograph/source/ui/AudioGraphEditorPanel.h b/examples/audiograph/source/ui/AudioGraphEditorPanel.h index d575a1c3b..cbdcacb7a 100644 --- a/examples/audiograph/source/ui/AudioGraphEditorPanel.h +++ b/examples/audiograph/source/ui/AudioGraphEditorPanel.h @@ -21,6 +21,7 @@ #pragma once +#include #include #include #include @@ -36,15 +37,32 @@ class AudioGraphEditorPanel final , public yup::AudioGraphComponent::Listener { public: + struct EndpointViews + { + std::function()> createInputView; + std::function()> createOutputView; + yup::Point defaultInputPosition { 40.0f, 200.0f }; + yup::Point defaultOutputPosition { 760.0f, 200.0f }; + }; + AudioGraphEditorPanel (std::shared_ptr graphIn, NodeRegistry& nodeRegistryIn, yup::StringRef endpointSubtitleIn) + : AudioGraphEditorPanel (graphIn, + nodeRegistryIn, + makeGraphEndpointViews (graphIn, endpointSubtitleIn)) + { + } + + AudioGraphEditorPanel (std::shared_ptr graphIn, + NodeRegistry& nodeRegistryIn, + EndpointViews endpointViewsIn) : graph (std::move (graphIn)) , model (graph != nullptr ? graph->getModel() : nullptr) , nodeRegistry (nodeRegistryIn) - , endpointSubtitle (endpointSubtitleIn) + , endpointViews (std::move (endpointViewsIn)) { - graphComponent = std::make_unique (graph); + graphComponent = std::make_unique (graph, &undoManager); graphComponent->onConnectionRequested = [this] (const yup::AudioGraphConnection& connection) { @@ -79,6 +97,16 @@ class AudioGraphEditorPanel final std::shared_ptr getGraph() const noexcept { return graph; } + void clearUndoHistory() + { + undoManager.clear(); + } + + void zoomToFitNodes() + { + graphComponent->zoomToFitNodes(); + } + void resized() override { graphComponent->setBounds (getLocalBounds()); @@ -111,19 +139,25 @@ class AudioGraphEditorPanel final showAddNodeMenu (canvasPos); } - void reloadViews() + void reloadViews (bool shouldZoomToFit = true) { - for (auto& [nodeID, _] : loadedNodes) - graphComponent->removeNodeView (nodeID); - - loadedNodes.clear(); + clearNodeViews(); for (auto nodeID : model->getNodeIDs()) addViewForNode (nodeID); - graphComponent->setGraphInputView (std::make_unique (graph, endpointSubtitle), { 40.0f, 200.0f }); - graphComponent->setGraphOutputView (std::make_unique (graph, endpointSubtitle), { 760.0f, 200.0f }); - graphComponent->zoomToFitNodes(); + reloadBoundaryViews(); + + if (shouldZoomToFit) + graphComponent->zoomToFitNodes(); + } + + void clearNodeViews() + { + for (auto& [nodeID, _] : loadedNodes) + graphComponent->removeNodeView (nodeID); + + loadedNodes.clear(); } void refreshNodeView (yup::AudioGraphNodeID nodeID) @@ -133,24 +167,17 @@ class AudioGraphEditorPanel final addViewForNode (nodeID); } - void addProcessorNode (std::unique_ptr processor, + bool addProcessorNode (std::unique_ptr processor, yup::AudioGraphNodeProperties props, yup::Point canvasPos) { - const auto identifier = props.identifier; - const auto nodeID = model->addNode (std::move (processor), std::move (props)); + const auto before = model->createSnapshot(); + const auto nodeID = addProcessorNodeWithoutUndo (std::move (processor), std::move (props), canvasPos); if (! nodeID.isValid()) - return; - - graph->commitChanges(); + return false; - auto* rawProc = model->getNodeProcessor (nodeID); - auto view = nodeRegistry.createView (nodeID, identifier, rawProc, graph.get()); - if (view != nullptr) - { - graphComponent->addNodeView (nodeID, std::move (view), canvasPos); - loadedNodes[nodeID] = identifier; - } + addSnapshotUndoAction (before, model->createSnapshot(), "Add Audio Graph Node"); + return true; } void addInternalNode (const yup::String& identifier, @@ -168,20 +195,180 @@ class AudioGraphEditorPanel final auto processorResult = nodeRegistry.makeProcessorFactory() (props); if (processorResult.failed()) + { + sendStatusMessage ("Failed: " + processorResult.getErrorMessage()); return; + } addProcessorNode (std::move (processorResult).getValue(), std::move (props), canvasPos); } + std::function onStatusMessage; std::function onNodeDoubleClicked; std::function onNodeWillBeRemoved; #if YUP_DESKTOP std::function)> onPluginSelected; std::function&()> getDiscoveredPlugins; + std::function getPluginMenuItemText; #endif private: + static EndpointViews makeGraphEndpointViews (std::shared_ptr graph, + yup::String endpointSubtitle) + { + EndpointViews views; + views.createInputView = [graph, endpointSubtitle] + { + return std::make_unique (graph, endpointSubtitle); + }; + views.createOutputView = [graph, endpointSubtitle] + { + return std::make_unique (graph, endpointSubtitle); + }; + return views; + } + + void reloadBoundaryViews() + { + if (endpointViews.createInputView != nullptr) + { + const auto input = model->getNodeProperties (yup::AudioGraphModel::getGraphInputNodeID()) + .value_or (makeEndpointProperties (endpointViews.defaultInputPosition)); + graphComponent->setGraphInputView (endpointViews.createInputView(), + getBoundaryPositionOrDefault (input, endpointViews.defaultInputPosition)); + } + + if (endpointViews.createOutputView != nullptr) + { + const auto output = model->getNodeProperties (yup::AudioGraphModel::getGraphOutputNodeID()) + .value_or (makeEndpointProperties (endpointViews.defaultOutputPosition)); + graphComponent->setGraphOutputView (endpointViews.createOutputView(), + getBoundaryPositionOrDefault (output, endpointViews.defaultOutputPosition)); + } + } + + static yup::AudioGraphNodeProperties makeEndpointProperties (yup::Point position) + { + yup::AudioGraphNodeProperties props; + props.positionX = position.getX(); + props.positionY = position.getY(); + return props; + } + + static yup::Point getBoundaryPositionOrDefault (const yup::AudioGraphNodeProperties& props, + yup::Point defaultPosition) + { + if (props.positionX == 0.0f && props.positionY == 0.0f) + return defaultPosition; + + return { props.positionX, props.positionY }; + } + + void sendStatusMessage (const yup::String& message) + { + if (onStatusMessage != nullptr) + onStatusMessage (message); + } + + struct SnapshotAction final : public yup::UndoableAction + { + SnapshotAction (AudioGraphEditorPanel& panelToUse, + yup::AudioGraphModel::Snapshot beforeToUse, + yup::AudioGraphModel::Snapshot afterToUse) + : panel (&panelToUse) + , before (std::move (beforeToUse)) + , after (std::move (afterToUse)) + { + } + + bool isValid() const override + { + return panel != nullptr; + } + + bool perform (yup::UndoableActionState stateToPerform) override + { + if (panel == nullptr) + return false; + + return panel->restoreSnapshotForUndo (stateToPerform == yup::UndoableActionState::Redo ? after : before); + } + + private: + AudioGraphEditorPanel* panel = nullptr; + yup::AudioGraphModel::Snapshot before; + yup::AudioGraphModel::Snapshot after; + }; + + void addSnapshotUndoAction (const yup::AudioGraphModel::Snapshot& before, + const yup::AudioGraphModel::Snapshot& after, + yup::StringRef transactionName) + { + yup::UndoManager::ScopedTransaction transaction (undoManager, transactionName); + undoManager.perform (new SnapshotAction (*this, before, after)); + } + + bool restoreSnapshotForUndo (const yup::AudioGraphModel::Snapshot& snapshot) + { + notifyNodesRemovedBySnapshot (snapshot); + + model->restoreSnapshot (snapshot); + const auto result = graph->commitChanges(); + reloadViews (false); + + return result.wasOk(); + } + + void notifyNodesRemovedBySnapshot (const yup::AudioGraphModel::Snapshot& snapshot) + { + if (onNodeWillBeRemoved == nullptr) + return; + + for (auto nodeID : model->getNodeIDs()) + { + const auto willStillExist = std::any_of (snapshot.nodes.begin(), snapshot.nodes.end(), [nodeID] (const yup::AudioGraphModel::NodeSnapshot& node) + { + return node.kind == yup::AudioGraphModel::NodeKind::processor && node.id == nodeID; + }); + + if (! willStillExist) + onNodeWillBeRemoved (*this, nodeID); + } + } + + yup::AudioGraphNodeID addProcessorNodeWithoutUndo (std::unique_ptr processor, + yup::AudioGraphNodeProperties props, + yup::Point canvasPos) + { + const auto identifier = props.identifier; + const auto nodeID = model->addNode (std::move (processor), std::move (props)); + if (! nodeID.isValid()) + { + sendStatusMessage ("Failed to add node to graph."); + return yup::AudioGraphNodeID::invalid(); + } + + const auto commitResult = graph->commitChanges(); + if (commitResult.failed()) + { + model->removeNode (nodeID); + graph->commitChanges(); + sendStatusMessage ("Failed: " + commitResult.getErrorMessage()); + return yup::AudioGraphNodeID::invalid(); + } + + auto* rawProc = model->getNodeProcessor (nodeID); + auto view = nodeRegistry.createView (nodeID, identifier, rawProc, graph.get()); + if (view != nullptr) + { + graphComponent->addNodeView (nodeID, std::move (view), canvasPos); + loadedNodes[nodeID] = identifier; + } + + return nodeID; + } + void addViewForNode (yup::AudioGraphNodeID nodeID) { auto props = model->getNodeProperties (nodeID); @@ -200,13 +387,30 @@ class AudioGraphEditorPanel final void removeNode (yup::AudioGraphNodeID nodeID) { + if (model->getNodeProcessor (nodeID) == nullptr) + return; + if (onNodeWillBeRemoved != nullptr) onNodeWillBeRemoved (*this, nodeID); + const auto before = model->createSnapshot(); + graphComponent->removeNodeView (nodeID); - model->removeNode (nodeID); - graph->commitChanges(); + if (! model->removeNode (nodeID)) + return; + + const auto commitResult = graph->commitChanges(); + if (commitResult.failed()) + { + model->restoreSnapshot (before); + graph->commitChanges(); + refreshNodeView (nodeID); + sendStatusMessage ("Failed: " + commitResult.getErrorMessage()); + return; + } + loadedNodes.erase (nodeID); + addSnapshotUndoAction (before, model->createSnapshot(), "Remove Audio Graph Node"); } bool addConnection (const yup::AudioGraphConnection& connection) @@ -305,10 +509,18 @@ class AudioGraphEditorPanel final auto pluginSubMenu = yup::PopupMenu::create(); for (int i = 0; i < static_cast (plugins.size()); ++i) - pluginSubMenu->addItem (plugins[static_cast (i)].name, 100 + i); + { + const auto& desc = plugins[static_cast (i)]; + const auto menuText = getPluginMenuItemText != nullptr ? getPluginMenuItemText (desc) : desc.name; + pluginSubMenu->addItem (menuText, 100 + i); + } activeMenu->addSubMenu ("Plugins", pluginSubMenu); } + else + { + activeMenu->addItem ("No plugins (click Scan)", -1, false); + } } #endif @@ -348,7 +560,8 @@ class AudioGraphEditorPanel final std::shared_ptr graph; std::shared_ptr model; NodeRegistry& nodeRegistry; - yup::String endpointSubtitle; + EndpointViews endpointViews; + yup::UndoManager undoManager; std::unique_ptr graphComponent; std::map loadedNodes; yup::PopupMenu::Ptr activeMenu; diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp index 5453a3434..1e6af7e34 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp @@ -110,6 +110,14 @@ void modelWriteBase64Element (XmlElement& parent, const char* tagName, const Mem parent.addChildElement (element); } +void modelWriteOptionalBase64Element (XmlElement& parent, const char* tagName, const MemoryBlock& block) +{ + if (block.getSize() == 0) + return; + + modelWriteBase64Element (parent, tagName, block); +} + Result modelReadBase64Element (const XmlElement& parent, const char* tagName, MemoryBlock& block) { auto* element = parent.getChildByName (tagName); @@ -130,13 +138,24 @@ Result modelReadBase64Element (const XmlElement& parent, const char* tagName, Me return Result::ok(); } +Result modelReadOptionalBase64Element (const XmlElement& parent, const char* tagName, MemoryBlock& block) +{ + if (parent.getChildByName (tagName) == nullptr) + { + block.reset(); + return Result::ok(); + } + + return modelReadBase64Element (parent, tagName, block); +} + void modelWriteNodeProperties (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)); - modelWriteBase64Element (element, "creationData", properties.creationData); + modelWriteOptionalBase64Element (element, "creationData", properties.creationData); } Result modelReadNodeProperties (const XmlElement& element, AudioGraphNodeProperties& properties) @@ -146,7 +165,7 @@ Result modelReadNodeProperties (const XmlElement& element, AudioGraphNodePropert properties.positionX = static_cast (element.getDoubleAttribute ("positionX")); properties.positionY = static_cast (element.getDoubleAttribute ("positionY")); - return modelReadBase64Element (element, "creationData", properties.creationData); + return modelReadOptionalBase64Element (element, "creationData", properties.creationData); } String modelEndpointKindToString (AudioGraphEndpoint::Kind kind) @@ -533,6 +552,22 @@ ResultValue> AudioGraphModel::createXml() const modelWriteBase64Element (*nodeElement, "state", node.state); } + auto* boundaryNodesElement = new XmlElement ("boundaryNodes"); + root->addChildElement (boundaryNodesElement); + + for (const auto& node : snapshot.nodes) + { + const auto isGraphInput = node.kind == NodeKind::graphInput; + const auto isGraphOutput = node.kind == NodeKind::graphOutput; + + if (! isGraphInput && ! isGraphOutput) + continue; + + auto* nodeElement = new XmlElement (isGraphInput ? "graphInput" : "graphOutput"); + boundaryNodesElement->addChildElement (nodeElement); + modelWriteNodeProperties (*nodeElement, node.properties); + } + auto* connectionsElement = new XmlElement ("connections"); root->addChildElement (connectionsElement); @@ -584,6 +619,30 @@ Result AudioGraphModel::restoreFromXml (const XmlElement& xml) savedNodes.push_back (std::move (savedNode)); } + std::optional graphInputProperties; + std::optional graphOutputProperties; + + if (auto* boundaryNodesElement = xml.getChildByName ("boundaryNodes")) + { + if (auto* graphInputElement = boundaryNodesElement->getChildByName ("graphInput")) + { + AudioGraphNodeProperties properties; + if (const auto result = modelReadNodeProperties (*graphInputElement, properties); result.failed()) + return result; + + graphInputProperties = std::move (properties); + } + + if (auto* graphOutputElement = boundaryNodesElement->getChildByName ("graphOutput")) + { + AudioGraphNodeProperties properties; + if (const auto result = modelReadNodeProperties (*graphOutputElement, properties); result.failed()) + return result; + + graphOutputProperties = std::move (properties); + } + } + auto* connectionsElement = xml.getChildByName ("connections"); if (connectionsElement == nullptr) return Result::fail ("Audio graph state is missing connections"); @@ -613,7 +672,16 @@ Result AudioGraphModel::restoreFromXml (const XmlElement& xml) savedConnections.push_back ({ source, destination }); } - return restoreModel (savedNextNodeID, std::move (savedNodes), std::move (savedConnections)); + if (const auto result = restoreModel (savedNextNodeID, std::move (savedNodes), std::move (savedConnections)); result.failed()) + return result; + + if (graphInputProperties.has_value()) + setNodeProperties (getGraphInputNodeID(), std::move (*graphInputProperties)); + + if (graphOutputProperties.has_value()) + setNodeProperties (getGraphOutputNodeID(), std::move (*graphOutputProperties)); + + return Result::ok(); } AudioGraphModel::Snapshot AudioGraphModel::createSnapshot() const From 98f8d5319035efa709f6103e63046b0afbd0b32b Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 00:58:28 +0200 Subject: [PATCH 11/14] Support creationData in xml --- .../graph/yup_AudioGraphModel.cpp | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp index 1e6af7e34..3846fb346 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp @@ -110,14 +110,6 @@ void modelWriteBase64Element (XmlElement& parent, const char* tagName, const Mem parent.addChildElement (element); } -void modelWriteOptionalBase64Element (XmlElement& parent, const char* tagName, const MemoryBlock& block) -{ - if (block.getSize() == 0) - return; - - modelWriteBase64Element (parent, tagName, block); -} - Result modelReadBase64Element (const XmlElement& parent, const char* tagName, MemoryBlock& block) { auto* element = parent.getChildByName (tagName); @@ -138,15 +130,45 @@ Result modelReadBase64Element (const XmlElement& parent, const char* tagName, Me return Result::ok(); } -Result modelReadOptionalBase64Element (const XmlElement& parent, const char* tagName, MemoryBlock& block) +void modelWriteCreationDataElement (XmlElement& parent, const MemoryBlock& block) { - if (parent.getChildByName (tagName) == nullptr) + if (block.getSize() == 0) + return; + + MemoryInputStream stream (block, false); + auto xml = parseXML (stream.readEntireStreamAsString()); + if (xml == nullptr) + return; + + auto* element = new XmlElement ("creationData"); + element->addChildElement (new XmlElement (*xml)); + parent.addChildElement (element); +} + +Result modelReadCreationDataElement (const XmlElement& parent, MemoryBlock& block) +{ + auto* element = parent.getChildByName ("creationData"); + if (element == nullptr) { block.reset(); return Result::ok(); } - return modelReadBase64Element (parent, tagName, block); + if (element->getStringAttribute ("encoding") == "base64") + return modelReadBase64Element (parent, "creationData", block); + + auto* creationXml = element->getFirstChildElement(); + if (creationXml == nullptr) + { + block.reset(); + return Result::ok(); + } + + block.reset(); + MemoryOutputStream stream (block, false); + creationXml->writeTo (stream); + stream.flush(); + return Result::ok(); } void modelWriteNodeProperties (XmlElement& element, const AudioGraphNodeProperties& properties) @@ -155,7 +177,7 @@ void modelWriteNodeProperties (XmlElement& element, const AudioGraphNodeProperti element.setAttribute ("name", properties.name); element.setAttribute ("positionX", static_cast (properties.positionX)); element.setAttribute ("positionY", static_cast (properties.positionY)); - modelWriteOptionalBase64Element (element, "creationData", properties.creationData); + modelWriteCreationDataElement (element, properties.creationData); } Result modelReadNodeProperties (const XmlElement& element, AudioGraphNodeProperties& properties) @@ -165,7 +187,7 @@ Result modelReadNodeProperties (const XmlElement& element, AudioGraphNodePropert properties.positionX = static_cast (element.getDoubleAttribute ("positionX")); properties.positionY = static_cast (element.getDoubleAttribute ("positionY")); - return modelReadOptionalBase64Element (element, "creationData", properties.creationData); + return modelReadCreationDataElement (element, properties.creationData); } String modelEndpointKindToString (AudioGraphEndpoint::Kind kind) From 553eb330e84e9af8329d5b155b1dd437cbef5e8f Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 08:36:30 +0200 Subject: [PATCH 12/14] More audio graph cleanups --- examples/audiograph/source/AudioGraphApp.cpp | 729 ++++++++++++++++++ examples/audiograph/source/AudioGraphApp.h | 704 ++--------------- examples/audiograph/source/main.cpp | 13 - .../source/ui/AudioGraphEditorPanel.h | 7 + 4 files changed, 783 insertions(+), 670 deletions(-) create mode 100644 examples/audiograph/source/AudioGraphApp.cpp diff --git a/examples/audiograph/source/AudioGraphApp.cpp b/examples/audiograph/source/AudioGraphApp.cpp new file mode 100644 index 000000000..14f0efdae --- /dev/null +++ b/examples/audiograph/source/AudioGraphApp.cpp @@ -0,0 +1,729 @@ +/* + ============================================================================== + + 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 "AudioGraphApp.h" + +namespace +{ + +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 "?"; + } +} + +} // namespace + +//============================================================================== + +AudioGraphApp::AudioGraphApp() +{ + deviceManager.initialiseWithDefaultDevices (2, 2); + + model = std::make_shared(); + graph = std::make_shared (model); + 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 + + model->setNodeFactory (nodeRegistry.makeProcessorFactory()); + + editorPanel = createMainPanel(); + addAndMakeVisible (*editorPanel); + + setupToolbar(); + resetToEmptyGraph(); + +#if YUP_DESKTOP + startPluginScan(); +#endif +} + +AudioGraphApp::~AudioGraphApp() +{ +#if YUP_DESKTOP + scanLifetime->store (false); +#endif + + closePluginEditor(); + closeAllSubgraphEditors(); + + if (audioCallbackRegistered) + { + deviceManager.removeAudioCallback (this); + audioCallbackRegistered = false; + } + + deviceManager.closeAudioDevice(); +} + +//============================================================================== + +void AudioGraphApp::resized() +{ + 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)); + + editorPanel->setBounds (bounds); +} + +void AudioGraphApp::paint (yup::Graphics& g) +{ + g.setFillColor (yup::Color (0xff0d1117)); + g.fillAll(); + + g.setFillColor (yup::Color (0xff161b22)); + g.fillRect (getLocalBounds().removeFromTop (36).to()); +} + +void AudioGraphApp::visibilityChanged() +{ + if (isVisible()) + { + deviceManager.addAudioCallback (this); + audioCallbackRegistered = true; + } + else if (audioCallbackRegistered) + { + deviceManager.removeAudioCallback (this); + audioCallbackRegistered = false; + } +} + +//============================================================================== + +void AudioGraphApp::audioDeviceIOCallbackWithContext (const float* const* inputChannelData, + int numInputChannels, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const yup::AudioIODeviceCallbackContext&) +{ + 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 AudioGraphApp::audioDeviceAboutToStart (yup::AudioIODevice* device) +{ + 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 AudioGraphApp::audioDeviceStopped() +{ + if (graph != nullptr) + graph->releaseResources(); +} + +//============================================================================== + +void AudioGraphApp::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 AudioGraphApp::resetToEmptyGraph() +{ + closePluginEditor(); + closeAllSubgraphEditors(); + + editorPanel->clearNodeViews(); + model->clear(); + model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); + model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); + graph->commitChanges(); + editorPanel->clearUndoHistory(); + currentFilePath = yup::File(); + + editorPanel->reloadViews(); +} + +void AudioGraphApp::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 AudioGraphApp::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 AudioGraphApp::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 AudioGraphApp::loadGraphFromMemory (const yup::MemoryBlock& mb, const yup::File& file) +{ + closePluginEditor(); + closeAllSubgraphEditors(); + + editorPanel->clearNodeViews(); + + model->clear(); + + const auto result = graph->loadStateFromMemory (mb); + if (result.failed()) + { + statusLabel.setText ("Load failed: " + result.getErrorMessage(), yup::dontSendNotification); + graph->commitChanges(); + return; + } + + model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); + model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); + graph->commitChanges(); + editorPanel->clearUndoHistory(); + currentFilePath = file; + + editorPanel->reloadViews(); + + statusLabel.setText ("Loaded: " + file.getFileName(), yup::dontSendNotification); +} + +//============================================================================== + +void AudioGraphApp::openNodeEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) +{ + if (ownerGraph == nullptr) + return; + + auto ownerModel = ownerGraph->getModel(); + auto* proc = ownerModel->getNodeProcessor (nodeID); + if (auto* subgraph = dynamic_cast (proc)) + { + openSubgraphEditor (ownerGraph, nodeID, *subgraph); + return; + } + + openPluginEditor (ownerGraph, nodeID); +} + +void AudioGraphApp::openPluginEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) +{ + if (pluginEditorWindow != nullptr && activePluginEditorGraph == ownerGraph.get() && activePluginEditorNodeID == nodeID) + { + pluginEditorWindow->toFront (true); + return; + } + + auto ownerModel = ownerGraph->getModel(); + auto* proc = ownerModel->getNodeProcessor (nodeID); + if (proc == nullptr || ! proc->hasEditor()) + return; + + auto editor = std::unique_ptr (proc->createEditor()); + if (editor == nullptr) + return; + + closePluginEditor(); + activePluginEditorGraph = ownerGraph.get(); + 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 AudioGraphApp::closePluginEditor() +{ + pluginEditorWindow.reset(); + activePluginEditorGraph = nullptr; + activePluginEditorNodeID = yup::AudioGraphNodeID::invalid(); +} + +//============================================================================== + +struct SubgraphEditorRecord +{ + const yup::AudioGraphProcessor* ownerGraphRaw = nullptr; + yup::AudioGraphNodeID nodeID; + std::shared_ptr ownerGraph; + std::unique_ptr window; +}; + +std::unique_ptr AudioGraphApp::createMainPanel() +{ + AudioGraphEditorPanel::EndpointViews endpointViews; + endpointViews.createInputView = [] + { + return std::make_unique(); + }; + endpointViews.createOutputView = [] + { + return std::make_unique(); + }; + + auto panel = std::make_unique (graph, nodeRegistry, std::move (endpointViews)); + configurePanelCallbacks (*panel); + return panel; +} + +std::unique_ptr AudioGraphApp::createSubgraphPanel (std::shared_ptr childGraph) +{ + auto panel = std::make_unique (std::move (childGraph), nodeRegistry, "parent graph"); + configurePanelCallbacks (*panel); + return panel; +} + +void AudioGraphApp::configurePanelCallbacks (AudioGraphEditorPanel& panel) +{ + panel.onStatusMessage = [this] (const yup::String& message) + { + statusLabel.setText (message, yup::dontSendNotification); + }; + + panel.onNodeDoubleClicked = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) + { + openNodeEditor (editor.getGraph(), nodeID); + }; + + panel.onNodeWillBeRemoved = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) + { + if (activePluginEditorGraph == editor.getGraph().get() && activePluginEditorNodeID == nodeID) + closePluginEditor(); + + closeSubgraphEditorForNode (editor.getGraph(), nodeID); + }; + +#if YUP_DESKTOP + panel.getDiscoveredPlugins = [this]() -> const std::vector& + { + return nodeRegistry.getDiscoveredPlugins(); + }; + + panel.getPluginMenuItemText = [] (const yup::AudioPluginDescription& desc) + { + return desc.name + " [" + formatTypeToString (desc.formatType) + "]"; + }; + + panel.onPluginSelected = [this] (AudioGraphEditorPanel& editor, + const yup::AudioPluginDescription& desc, + yup::Point canvasPos) + { + addPluginNodeToPanel (editor, desc, canvasPos); + }; +#endif +} + +void AudioGraphApp::openSubgraphEditor (std::shared_ptr ownerGraph, + yup::AudioGraphNodeID nodeID, + SubgraphProcessor& processor) +{ + if (auto* record = findSubgraphEditorRecord (ownerGraph.get(), nodeID)) + { + record->window->toFront (true); + return; + } + + auto childGraph = processor.getGraph(); + auto panel = createSubgraphPanel (childGraph); + + yup::WeakReference weakThis (this); + auto window = std::make_unique ( + processor.getConfig().getDisplayName(), + std::move (panel), + processor.getConfig(), + [weakThis, ownerGraph, nodeID] (int presetID) + { + if (auto* self = weakThis.get()) + self->reconfigureSubgraph (ownerGraph, nodeID, SubgraphConfig::fromPreset (presetID)); + }, + [weakThis, ownerGraph, nodeID] + { + if (auto* self = weakThis.get()) + self->closeSubgraphEditorForNode (ownerGraph, nodeID); + }); + + subgraphEditorWindows.push_back ({ ownerGraph.get(), nodeID, ownerGraph, std::move (window) }); + auto& record = subgraphEditorWindows.back(); + record.window->centreWithSize ({ 900, 620 }); + record.window->setVisible (true); + record.window->toFront (true); +} + +void AudioGraphApp::reconfigureSubgraph (std::shared_ptr ownerGraph, + yup::AudioGraphNodeID nodeID, + const SubgraphConfig& config) +{ + if (ownerGraph == nullptr) + return; + + auto ownerModel = ownerGraph->getModel(); + auto* oldProcessor = dynamic_cast (ownerModel->getNodeProcessor (nodeID)); + if (oldProcessor == nullptr || oldProcessor->getConfig().getPresetID() == config.getPresetID()) + return; + + closePluginEditor(); + closeSubgraphEditorsForGraph (oldProcessor->getGraph().get()); + + yup::MemoryBlock oldState; + if (const auto saveResult = oldProcessor->saveStateIntoMemory (oldState); saveResult.failed()) + { + statusLabel.setText ("Subgraph save failed: " + saveResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + auto props = ownerModel->getNodeProperties (nodeID).value_or (yup::AudioGraphNodeProperties()); + props.identifier = NodeRegistry::subgraphIdentifier; + props.name = config.getDisplayName(); + props.creationData = SubgraphConfig::toCreationData (config); + + auto replacement = std::make_unique (config); + auto replacementModel = replacement->getModel(); + replacementModel->setNodeFactory (nodeRegistry.makeProcessorFactory()); + + if (const auto loadResult = replacement->loadStateFromMemory (oldState); loadResult.failed()) + { + statusLabel.setText ("Subgraph reload failed: " + loadResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + if (const auto replaceResult = ownerModel->replaceNode (nodeID, std::move (replacement), props); replaceResult.failed()) + { + statusLabel.setText ("Subgraph reconfigure failed: " + replaceResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + if (const auto commitResult = ownerGraph->commitChanges(); commitResult.failed()) + { + statusLabel.setText ("Subgraph commit failed: " + commitResult.getErrorMessage(), yup::dontSendNotification); + return; + } + + refreshNodeViewsForGraph (ownerGraph.get(), nodeID); + + auto* newProcessor = dynamic_cast (ownerModel->getNodeProcessor (nodeID)); + auto* record = findSubgraphEditorRecord (ownerGraph.get(), nodeID); + + if (newProcessor != nullptr && record != nullptr) + { + record->window->setTitle (config.getDisplayName()); + record->window->setEditorPanel (createSubgraphPanel (newProcessor->getGraph()), config); + } +} + +void AudioGraphApp::refreshNodeViewsForGraph (const yup::AudioGraphProcessor* graphToRefresh, yup::AudioGraphNodeID nodeID) +{ + if (graphToRefresh == graph.get()) + editorPanel->refreshNodeView (nodeID); + + for (auto& record : subgraphEditorWindows) + { + if (record.window == nullptr) + continue; + + auto* panel = record.window->getEditorPanel(); + if (panel != nullptr && panel->getGraph().get() == graphToRefresh) + panel->refreshNodeView (nodeID); + } +} + +void AudioGraphApp::closeSubgraphEditorForNode (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) +{ + if (ownerGraph == nullptr) + return; + + auto ownerModel = ownerGraph->getModel(); + if (auto* subgraph = dynamic_cast (ownerModel->getNodeProcessor (nodeID))) + closeSubgraphEditorsForGraph (subgraph->getGraph().get()); + + std::erase_if (subgraphEditorWindows, [ownerGraphRaw = ownerGraph.get(), nodeID] (SubgraphEditorRecord& record) + { + return record.ownerGraphRaw == ownerGraphRaw && record.nodeID == nodeID; + }); +} + +void AudioGraphApp::closeSubgraphEditorsForGraph (const yup::AudioGraphProcessor* graphToClose) +{ + bool removed = true; + + while (removed) + { + removed = false; + + for (auto& record : subgraphEditorWindows) + { + if (record.ownerGraphRaw == graphToClose) + { + closeSubgraphEditorForNode (record.ownerGraph, record.nodeID); + removed = true; + break; + } + } + } +} + +void AudioGraphApp::closeAllSubgraphEditors() +{ + subgraphEditorWindows.clear(); +} + +AudioGraphApp::SubgraphEditorRecord* AudioGraphApp::findSubgraphEditorRecord (const yup::AudioGraphProcessor* ownerGraph, yup::AudioGraphNodeID nodeID) +{ + const auto iterator = std::find_if (subgraphEditorWindows.begin(), subgraphEditorWindows.end(), [ownerGraph, nodeID] (const SubgraphEditorRecord& record) + { + return record.ownerGraphRaw == ownerGraph && record.nodeID == nodeID; + }); + + return iterator != subgraphEditorWindows.end() ? &*iterator : nullptr; +} + +#if YUP_DESKTOP +void AudioGraphApp::addPluginNodeToPanel (AudioGraphEditorPanel& editor, + 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); + + if (editor.addProcessorNode (std::move (result).getValue(), std::move (props), canvasPos)) + statusLabel.setText ("Loaded: " + desc.name, yup::dontSendNotification); +} +#endif + +//============================================================================== + +#if YUP_DESKTOP +void AudioGraphApp::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 AudioGraphApp::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; +} +#endif diff --git a/examples/audiograph/source/AudioGraphApp.h b/examples/audiograph/source/AudioGraphApp.h index c2e0c5b73..8878c4a60 100644 --- a/examples/audiograph/source/AudioGraphApp.h +++ b/examples/audiograph/source/AudioGraphApp.h @@ -21,6 +21,21 @@ #pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if YUP_DESKTOP +#include +#endif + #include #include #include @@ -40,371 +55,43 @@ class AudioGraphApp final , public yup::AudioIODeviceCallback { public: - AudioGraphApp() - { - deviceManager.initialiseWithDefaultDevices (2, 2); - - model = std::make_shared(); - graph = std::make_shared (model); - 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 - - model->setNodeFactory (nodeRegistry.makeProcessorFactory()); - - editorPanel = createMainPanel(); - addAndMakeVisible (*editorPanel); - - setupToolbar(); - resetToEmptyGraph(); - -#if YUP_DESKTOP - startPluginScan(); -#endif - } - - ~AudioGraphApp() override - { -#if YUP_DESKTOP - scanLifetime->store (false); -#endif - - closePluginEditor(); - closeAllSubgraphEditors(); - - if (audioCallbackRegistered) - { - deviceManager.removeAudioCallback (this); - audioCallbackRegistered = false; - } - - deviceManager.closeAudioDevice(); - } - - void resized() override - { - auto bounds = getLocalBounds(); - auto toolbar = bounds.removeFromTop (36); + AudioGraphApp(); + ~AudioGraphApp() override; - 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)); + // Component + void resized() override; + void paint (yup::Graphics& g) override; + void visibilityChanged() override; - editorPanel->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(); - } + const yup::AudioIODeviceCallbackContext&) override; + void audioDeviceAboutToStart (yup::AudioIODevice* device) override; + void audioDeviceStopped() override; 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(); - closeAllSubgraphEditors(); - - editorPanel->clearNodeViews(); - model->clear(); - model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); - model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); - graph->commitChanges(); - editorPanel->clearUndoHistory(); - currentFilePath = yup::File(); - - editorPanel->reloadViews(); - } + void setupToolbar(); //============================================================================== + void resetToEmptyGraph(); - 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(); - closeAllSubgraphEditors(); - - editorPanel->clearNodeViews(); - - model->clear(); - - const auto result = graph->loadStateFromMemory (mb); - if (result.failed()) - { - statusLabel.setText ("Load failed: " + result.getErrorMessage(), yup::dontSendNotification); - graph->commitChanges(); - return; - } + void openGraph(); + void saveGraph(); + void saveGraphToFile (const yup::File& file); - model->setNodePosition (yup::AudioGraphModel::getGraphInputNodeID(), 40.0f, 200.0f); - model->setNodePosition (yup::AudioGraphModel::getGraphOutputNodeID(), 760.0f, 200.0f); - graph->commitChanges(); - editorPanel->clearUndoHistory(); - currentFilePath = file; - - editorPanel->reloadViews(); - - statusLabel.setText ("Loaded: " + file.getFileName(), yup::dontSendNotification); - } + void loadGraphFromMemory (const yup::MemoryBlock& mb, const yup::File& file); //============================================================================== + void openNodeEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID); + void openPluginEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID); + void closePluginEditor(); - void openNodeEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) - { - if (ownerGraph == nullptr) - return; - - auto ownerModel = ownerGraph->getModel(); - auto* proc = ownerModel->getNodeProcessor (nodeID); - if (auto* subgraph = dynamic_cast (proc)) - { - openSubgraphEditor (ownerGraph, nodeID, *subgraph); - return; - } - - openPluginEditor (ownerGraph, nodeID); - } - - void openPluginEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) - { - if (pluginEditorWindow != nullptr && activePluginEditorGraph == ownerGraph.get() && activePluginEditorNodeID == nodeID) - { - pluginEditorWindow->toFront (true); - return; - } - - auto ownerModel = ownerGraph->getModel(); - auto* proc = ownerModel->getNodeProcessor (nodeID); - if (proc == nullptr || ! proc->hasEditor()) - return; - - auto editor = std::unique_ptr (proc->createEditor()); - if (editor == nullptr) - return; - - closePluginEditor(); - activePluginEditorGraph = ownerGraph.get(); - 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(); - activePluginEditorGraph = nullptr; - activePluginEditorNodeID = yup::AudioGraphNodeID::invalid(); - } - + //============================================================================== struct SubgraphEditorRecord { const yup::AudioGraphProcessor* ownerGraphRaw = nullptr; @@ -413,325 +100,28 @@ class AudioGraphApp final std::unique_ptr window; }; - std::unique_ptr createMainPanel() - { - AudioGraphEditorPanel::EndpointViews endpointViews; - endpointViews.createInputView = [] - { - return std::make_unique(); - }; - endpointViews.createOutputView = [] - { - return std::make_unique(); - }; - - auto panel = std::make_unique (graph, nodeRegistry, std::move (endpointViews)); - configurePanelCallbacks (*panel); - return panel; - } - - std::unique_ptr createSubgraphPanel (std::shared_ptr childGraph) - { - auto panel = std::make_unique (std::move (childGraph), nodeRegistry, "parent graph"); - configurePanelCallbacks (*panel); - return panel; - } - - void configurePanelCallbacks (AudioGraphEditorPanel& panel) - { - panel.onStatusMessage = [this] (const yup::String& message) - { - statusLabel.setText (message, yup::dontSendNotification); - }; - - panel.onNodeDoubleClicked = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) - { - openNodeEditor (editor.getGraph(), nodeID); - }; - - panel.onNodeWillBeRemoved = [this] (AudioGraphEditorPanel& editor, yup::AudioGraphNodeID nodeID) - { - if (activePluginEditorGraph == editor.getGraph().get() && activePluginEditorNodeID == nodeID) - closePluginEditor(); - - closeSubgraphEditorForNode (editor.getGraph(), nodeID); - }; - -#if YUP_DESKTOP - panel.getDiscoveredPlugins = [this]() -> const std::vector& - { - return nodeRegistry.getDiscoveredPlugins(); - }; - panel.getPluginMenuItemText = [] (const yup::AudioPluginDescription& desc) - { - return desc.name + " [" + formatTypeToString (desc.formatType) + "]"; - }; - panel.onPluginSelected = [this] (AudioGraphEditorPanel& editor, - const yup::AudioPluginDescription& desc, - yup::Point canvasPos) - { - addPluginNodeToPanel (editor, desc, canvasPos); - }; -#endif - } + std::unique_ptr createMainPanel(); + std::unique_ptr createSubgraphPanel (std::shared_ptr childGraph); + void configurePanelCallbacks (AudioGraphEditorPanel& panel); void openSubgraphEditor (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID, - SubgraphProcessor& processor) - { - if (auto* record = findSubgraphEditorRecord (ownerGraph.get(), nodeID)) - { - record->window->toFront (true); - return; - } - - auto childGraph = processor.getGraph(); - auto panel = createSubgraphPanel (childGraph); - - yup::WeakReference weakThis (this); - auto window = std::make_unique ( - processor.getConfig().getDisplayName(), - std::move (panel), - processor.getConfig(), - [weakThis, ownerGraph, nodeID] (int presetID) - { - if (auto* self = weakThis.get()) - self->reconfigureSubgraph (ownerGraph, nodeID, SubgraphConfig::fromPreset (presetID)); - }, - [weakThis, ownerGraph, nodeID] - { - if (auto* self = weakThis.get()) - self->closeSubgraphEditorForNode (ownerGraph, nodeID); - }); - - subgraphEditorWindows.push_back ({ ownerGraph.get(), nodeID, ownerGraph, std::move (window) }); - auto& record = subgraphEditorWindows.back(); - record.window->centreWithSize ({ 900, 620 }); - record.window->setVisible (true); - record.window->toFront (true); - } - + SubgraphProcessor& processor); void reconfigureSubgraph (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID, - const SubgraphConfig& config) - { - if (ownerGraph == nullptr) - return; - - auto ownerModel = ownerGraph->getModel(); - auto* oldProcessor = dynamic_cast (ownerModel->getNodeProcessor (nodeID)); - if (oldProcessor == nullptr || oldProcessor->getConfig().getPresetID() == config.getPresetID()) - return; - - closePluginEditor(); - closeSubgraphEditorsForGraph (oldProcessor->getGraph().get()); - - yup::MemoryBlock oldState; - if (const auto saveResult = oldProcessor->saveStateIntoMemory (oldState); saveResult.failed()) - { - statusLabel.setText ("Subgraph save failed: " + saveResult.getErrorMessage(), yup::dontSendNotification); - return; - } - - auto props = ownerModel->getNodeProperties (nodeID).value_or (yup::AudioGraphNodeProperties()); - props.identifier = NodeRegistry::subgraphIdentifier; - props.name = config.getDisplayName(); - props.creationData = SubgraphConfig::toCreationData (config); - - auto replacement = std::make_unique (config); - auto replacementModel = replacement->getModel(); - replacementModel->setNodeFactory (nodeRegistry.makeProcessorFactory()); - - if (const auto loadResult = replacement->loadStateFromMemory (oldState); loadResult.failed()) - { - statusLabel.setText ("Subgraph reload failed: " + loadResult.getErrorMessage(), yup::dontSendNotification); - return; - } - - if (const auto replaceResult = ownerModel->replaceNode (nodeID, std::move (replacement), props); replaceResult.failed()) - { - statusLabel.setText ("Subgraph reconfigure failed: " + replaceResult.getErrorMessage(), yup::dontSendNotification); - return; - } - - if (const auto commitResult = ownerGraph->commitChanges(); commitResult.failed()) - { - statusLabel.setText ("Subgraph commit failed: " + commitResult.getErrorMessage(), yup::dontSendNotification); - return; - } - - refreshNodeViewsForGraph (ownerGraph.get(), nodeID); - - auto* newProcessor = dynamic_cast (ownerModel->getNodeProcessor (nodeID)); - auto* record = findSubgraphEditorRecord (ownerGraph.get(), nodeID); - - if (newProcessor != nullptr && record != nullptr) - { - record->window->setTitle (config.getDisplayName()); - record->window->setEditorPanel (createSubgraphPanel (newProcessor->getGraph()), config); - } - } - - void refreshNodeViewsForGraph (const yup::AudioGraphProcessor* graphToRefresh, yup::AudioGraphNodeID nodeID) - { - if (graphToRefresh == graph.get()) - editorPanel->refreshNodeView (nodeID); - - for (auto& record : subgraphEditorWindows) - { - if (record.window == nullptr) - continue; - - auto* panel = record.window->getEditorPanel(); - if (panel != nullptr && panel->getGraph().get() == graphToRefresh) - panel->refreshNodeView (nodeID); - } - } - - void closeSubgraphEditorForNode (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID) - { - if (ownerGraph == nullptr) - return; - - auto ownerModel = ownerGraph->getModel(); - if (auto* subgraph = dynamic_cast (ownerModel->getNodeProcessor (nodeID))) - closeSubgraphEditorsForGraph (subgraph->getGraph().get()); - - subgraphEditorWindows.erase (std::remove_if (subgraphEditorWindows.begin(), subgraphEditorWindows.end(), [ownerGraphRaw = ownerGraph.get(), nodeID] (SubgraphEditorRecord& record) - { - return record.ownerGraphRaw == ownerGraphRaw && record.nodeID == nodeID; - }), - subgraphEditorWindows.end()); - } - - void closeSubgraphEditorsForGraph (const yup::AudioGraphProcessor* graphToClose) - { - bool removed = true; - - while (removed) - { - removed = false; - - for (auto& record : subgraphEditorWindows) - { - if (record.ownerGraphRaw == graphToClose) - { - closeSubgraphEditorForNode (record.ownerGraph, record.nodeID); - removed = true; - break; - } - } - } - } - - void closeAllSubgraphEditors() - { - subgraphEditorWindows.clear(); - } - - SubgraphEditorRecord* findSubgraphEditorRecord (const yup::AudioGraphProcessor* ownerGraph, yup::AudioGraphNodeID nodeID) - { - const auto iterator = std::find_if (subgraphEditorWindows.begin(), subgraphEditorWindows.end(), [ownerGraph, nodeID] (const SubgraphEditorRecord& record) - { - return record.ownerGraphRaw == ownerGraph && record.nodeID == nodeID; - }); - - return iterator != subgraphEditorWindows.end() ? &*iterator : nullptr; - } - -#if YUP_DESKTOP - void addPluginNodeToPanel (AudioGraphEditorPanel& editor, - 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); - - if (editor.addProcessorNode (std::move (result).getValue(), std::move (props), canvasPos)) - statusLabel.setText ("Loaded: " + desc.name, yup::dontSendNotification); - } -#endif + const SubgraphConfig& config); + void refreshNodeViewsForGraph (const yup::AudioGraphProcessor* graphToRefresh, yup::AudioGraphNodeID nodeID); + void closeSubgraphEditorForNode (std::shared_ptr ownerGraph, yup::AudioGraphNodeID nodeID); + void closeSubgraphEditorsForGraph (const yup::AudioGraphProcessor* graphToClose); + void closeAllSubgraphEditors(); + SubgraphEditorRecord* findSubgraphEditorRecord (const yup::AudioGraphProcessor* ownerGraph, yup::AudioGraphNodeID nodeID); //============================================================================== #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 "?"; - } - } + void startPluginScan(); + void addPluginNodeToPanel (AudioGraphEditorPanel& editor, const yup::AudioPluginDescription& desc, yup::Point canvasPos); + yup::AudioPluginHostContext makeHostContext() const; #endif //============================================================================== diff --git a/examples/audiograph/source/main.cpp b/examples/audiograph/source/main.cpp index 42d1dce72..04cd6b453 100644 --- a/examples/audiograph/source/main.cpp +++ b/examples/audiograph/source/main.cpp @@ -19,20 +19,7 @@ ============================================================================== */ -#include -#include -#include -#include -#include -#include -#include -#include #include -#include - -#if YUP_DESKTOP -#include -#endif #include "AudioGraphApp.h" diff --git a/examples/audiograph/source/ui/AudioGraphEditorPanel.h b/examples/audiograph/source/ui/AudioGraphEditorPanel.h index cbdcacb7a..a4c730767 100644 --- a/examples/audiograph/source/ui/AudioGraphEditorPanel.h +++ b/examples/audiograph/source/ui/AudioGraphEditorPanel.h @@ -292,6 +292,12 @@ class AudioGraphEditorPanel final if (panel == nullptr) return false; + if (stateToPerform == yup::UndoableActionState::Redo && skipNextRedo) + { + skipNextRedo = false; + return true; + } + return panel->restoreSnapshotForUndo (stateToPerform == yup::UndoableActionState::Redo ? after : before); } @@ -299,6 +305,7 @@ class AudioGraphEditorPanel final AudioGraphEditorPanel* panel = nullptr; yup::AudioGraphModel::Snapshot before; yup::AudioGraphModel::Snapshot after; + bool skipNextRedo = true; }; void addSnapshotUndoAction (const yup::AudioGraphModel::Snapshot& before, From 9ea42ad06af7d4eb78dfe06696285af3763771aa Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 07:54:41 +0200 Subject: [PATCH 13/14] More fixes --- .../graph/yup_AudioGraphProcessor.cpp | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp index 3e0f42338..9b79604aa 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphProcessor.cpp @@ -122,6 +122,22 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener std::atomic& flag; }; + struct ScopedProcessBlock + { + explicit ScopedProcessBlock (std::atomic& counterIn) noexcept + : counter (counterIn) + { + counter.fetch_add (1, std::memory_order_acq_rel); + } + + ~ScopedProcessBlock() + { + counter.fetch_sub (1, std::memory_order_acq_rel); + } + + std::atomic& counter; + }; + struct DelayLine { void initialise (GraphSignalType signalTypeIn, int numChannels, int delaySamplesIn, int maxBlockSize, size_t midiReserveBytes = 4096) @@ -301,7 +317,7 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener delete pendingPlan.exchange (compiled.release()); hasPublishedPlan.store (true); - deleteRetiredPlans(); + deleteRetiredPlansIfUnused(); if (oldLatencySamples != newLatencySamples) owner.updateHostDisplay (AudioProcessor::ChangeDetails().withLatencyChanged (true)); @@ -400,6 +416,7 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener void processBlock (AudioBuffer& audioBuffer, MidiBuffer& midiBuffer) { ScopedNoDenormals noDenormals; + const ScopedProcessBlock scopedProcessBlock (activeProcessBlocks); swapPendingPlan(); auto* graph = currentPlan; @@ -1096,6 +1113,12 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener } } + void deleteRetiredPlansIfUnused() + { + if (activeProcessBlocks.load (std::memory_order_acquire) == 0) + deleteRetiredPlans(); + } + void resizeWorkers (int newNumThreads) { while (static_cast (workers.size()) > newNumThreads) @@ -1190,6 +1213,7 @@ class AudioGraphProcessor::Pimpl final : private AudioProcessor::Listener float lastCompiledSampleRate = 0.0f; int lastCompiledMaxBlockSize = 0; std::atomic hasPublishedPlan { false }; + std::atomic activeProcessBlocks { 0 }; std::atomic pendingPlan { nullptr }; std::atomic retiredPlans { nullptr }; CompiledGraph* currentPlan = nullptr; From 1d4612f61d106233e697ee856ef0da447a43ffd9 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 10:21:58 +0200 Subject: [PATCH 14/14] Fix issues --- .../graph/yup_AudioGraphModel.cpp | 3 -- .../yup_AudioGraphProcessor.cpp | 45 ++++++++++++++----- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp index 3846fb346..19fe85f38 100644 --- a/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp +++ b/modules/yup_audio_graph/graph/yup_AudioGraphModel.cpp @@ -154,9 +154,6 @@ Result modelReadCreationDataElement (const XmlElement& parent, MemoryBlock& bloc return Result::ok(); } - if (element->getStringAttribute ("encoding") == "base64") - return modelReadBase64Element (parent, "creationData", block); - auto* creationXml = element->getFirstChildElement(); if (creationXml == nullptr) { diff --git a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp index b4a1766cd..3f2d83620 100644 --- a/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp +++ b/tests/yup_audio_graph/yup_AudioGraphProcessor.cpp @@ -519,9 +519,12 @@ AudioGraphNodeProperties externalProcessorProperties (float positionX = 0.0f, fl properties.positionX = positionX; properties.positionY = positionY; + XmlElement creationData ("externalPlugin"); + creationData.setAttribute ("pluginName", "Fake Plugin"); + creationData.setAttribute ("pluginIdentifier", "fake.plugin"); + MemoryOutputStream stream (properties.creationData, false); - stream.writeString ("Fake Plugin"); - stream.writeString ("fake.plugin"); + creationData.writeTo (stream); stream.flush(); return properties; @@ -538,8 +541,17 @@ AudioGraphModel::NodeFactory statefulGainAndExternalFactory (int& externalLoadCo return makeResultValueFail ("Unknown node type"); MemoryInputStream stream (properties.creationData, false); - const auto pluginName = stream.readString(); - externalIdentifier = stream.readString(); + const auto creationData = parseXML (stream.readEntireStreamAsString()); + + if (creationData == nullptr || ! creationData->hasTagName ("externalPlugin")) + return makeResultValueFail ("Invalid external plugin creation data"); + + const auto pluginName = creationData->getStringAttribute ("pluginName"); + externalIdentifier = creationData->getStringAttribute ("pluginIdentifier"); + + if (pluginName.isEmpty() || externalIdentifier.isEmpty()) + return makeResultValueFail ("Invalid external plugin creation data"); + ++externalLoadCount; return makeResultValueOk (std::make_unique (pluginName)); @@ -1391,7 +1403,7 @@ TEST (AudioGraphProcessorTests, LoadStateFailsWhenFactoryIsMissing) EXPECT_TRUE (destination.loadStateFromMemory (savedState).failed()); } -TEST (AudioGraphProcessorTests, LoadStateRecreatesExternalNodesWithOpaqueCreationData) +TEST (AudioGraphProcessorTests, LoadStateRecreatesExternalNodesWithXmlCreationData) { auto sourceModel = std::make_shared(); AudioGraphProcessor source (sourceModel); @@ -1411,8 +1423,12 @@ TEST (AudioGraphProcessorTests, LoadStateRecreatesExternalNodesWithOpaqueCreatio 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()); + EXPECT_TRUE (creationDataElement->getStringAttribute ("encoding").isEmpty()); + + auto* externalPluginElement = creationDataElement->getChildByName ("externalPlugin"); + ASSERT_NE (nullptr, externalPluginElement); + EXPECT_EQ (String ("Fake Plugin"), externalPluginElement->getStringAttribute ("pluginName")); + EXPECT_EQ (String ("fake.plugin"), externalPluginElement->getStringAttribute ("pluginIdentifier")); int externalLoadCount = 0; String externalIdentifier; @@ -1509,7 +1525,7 @@ TEST (AudioGraphProcessorTests, SaveStateWritesCompleteXmlTopology) 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")); + EXPECT_EQ (nullptr, gainElement->getChildByName ("creationData")); auto* externalElement = nodesElement->getChildByAttribute ("id", String (static_cast (externalNode.getRawID()))); ASSERT_NE (nullptr, externalElement); @@ -1517,7 +1533,14 @@ TEST (AudioGraphProcessorTests, SaveStateWritesCompleteXmlTopology) 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* creationDataElement = externalElement->getChildByName ("creationData"); + ASSERT_NE (nullptr, creationDataElement); + + auto* externalPluginElement = creationDataElement->getChildByName ("externalPlugin"); + ASSERT_NE (nullptr, externalPluginElement); + EXPECT_EQ (String ("Fake Plugin"), externalPluginElement->getStringAttribute ("pluginName")); + EXPECT_EQ (String ("fake.plugin"), externalPluginElement->getStringAttribute ("pluginIdentifier")); auto* connectionsElement = xml->getChildByName ("connections"); ASSERT_NE (nullptr, connectionsElement); @@ -1636,7 +1659,7 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsMissingRequiredXmlSections) EXPECT_TRUE (graph.loadStateFromMemory (memoryBlockFromString (missingConnectionsXml)).failed()); } -TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidBase64Payloads) +TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidNodeStateAndCreationDataPayloads) { auto sourceModel = std::make_shared(); AudioGraphProcessor source (sourceModel); @@ -1671,7 +1694,7 @@ TEST (AudioGraphProcessorTests, LoadStateRejectsInvalidBase64Payloads) auto* creationDataElement = invalidCreationData->getChildByName ("nodes")->getChildByName ("node")->getChildByName ("creationData"); ASSERT_NE (nullptr, creationDataElement); creationDataElement->deleteAllChildElements(); - creationDataElement->addTextElement ("not-base64"); + creationDataElement->addChildElement (new XmlElement ("externalPlugin")); EXPECT_TRUE (destination.loadStateFromMemory (memoryBlockFromXml (*invalidCreationData)).failed()); EXPECT_EQ (0, externalLoadCount);