diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp index ba939cefa..383a00f49 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ConvolverNodeHostObject.cpp @@ -67,17 +67,17 @@ void ConvolverNodeHostObject::setBuffer(const std::shared_ptr &buff } auto threadPool = std::make_shared(4); - std::vector convolvers; + std::vector> convolvers; for (size_t i = 0; i < copiedBuffer->getNumberOfChannels(); ++i) { AudioArray channelData(*copiedBuffer->getChannel(i)); convolvers.emplace_back(); - convolvers.back().init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize()); + convolvers.back()->init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize()); } if (copiedBuffer->getNumberOfChannels() == 1) { // add one more convolver, because right now input is always stereo AudioArray channelData(*copiedBuffer->getChannel(0)); convolvers.emplace_back(); - convolvers.back().init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize()); + convolvers.back()->init(RENDER_QUANTUM_SIZE, channelData, copiedBuffer->getSize()); } auto internalBuffer = std::make_shared( @@ -87,7 +87,7 @@ void ConvolverNodeHostObject::setBuffer(const std::shared_ptr &buff struct SetupData { std::shared_ptr buffer; - std::vector convolvers; + std::vector> convolvers; std::shared_ptr threadPool; std::shared_ptr internalBuffer; std::shared_ptr intermediateBuffer; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h index 8cdc71f34..1346291f7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioNode.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -18,7 +19,7 @@ namespace audioapi { class AudioParam; -class AudioNode : public std::enable_shared_from_this { +class AudioNode : public utils::graph::GraphObject, public std::enable_shared_from_this { public: explicit AudioNode( const std::shared_ptr &context, @@ -62,7 +63,15 @@ class AudioNode : public std::enable_shared_from_this { return false; } - virtual bool canBeDestructed() const; + bool canBeDestructed() const override; + + [[nodiscard]] AudioNode *asAudioNode() override { + return this; + } + + [[nodiscard]] const AudioNode *asAudioNode() const override { + return this; + } protected: friend class AudioGraphManager; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.h index 932e1a289..ebd875398 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioParam.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -15,7 +16,7 @@ namespace audioapi { -class AudioParam { +class AudioParam : public utils::graph::GraphObject { public: explicit AudioParam( float defaultValue, @@ -79,6 +80,19 @@ class AudioParam { return false; } + /// @brief Temporary lifecycle policy for GraphObject-based graph storage. + [[nodiscard]] bool canBeDestructed() const override { + return true; + } + + [[nodiscard]] AudioParam *asAudioParam() override { + return this; + } + + [[nodiscard]] const AudioParam *asAudioParam() const override { + return this; + } + /// Audio-Thread only methods /// These methods are called only from the Audio rendering thread. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index 67e4013e0..1f62ef555 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -38,10 +38,12 @@ BaseAudioContext::BaseAudioContext( const RuntimeRegistry &runtimeRegistry) : state_(ContextState::SUSPENDED), sampleRate_(sampleRate), - graphManager_(std::make_shared()), audioEventHandlerRegistry_(audioEventHandlerRegistry), runtimeRegistry_(runtimeRegistry), - audioEventScheduler_(AUDIO_SCHEDULER_CAPACITY) {} + audioEventScheduler_(AUDIO_SCHEDULER_CAPACITY), + disposer_( + std::make_unique>(AUDIO_SCHEDULER_CAPACITY)), + graphManager_(std::make_unique(this)) {} void BaseAudioContext::initialize() { destination_ = std::make_shared(shared_from_this()); @@ -244,8 +246,8 @@ std::shared_ptr BaseAudioContext::getBasicWaveForm(OscillatorType } } -std::shared_ptr BaseAudioContext::getGraphManager() const { - return graphManager_; +AudioGraphManager *BaseAudioContext::getGraphManager() const { + return graphManager_.get(); } std::shared_ptr BaseAudioContext::getAudioEventHandlerRegistry() const { @@ -256,4 +258,8 @@ const RuntimeRegistry &BaseAudioContext::getRuntimeRegistry() const { return runtimeRegistry_; } +utils::DisposerImpl *BaseAudioContext::getDisposer() const { + return disposer_.get(); +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index e3f52fd28..b4c4a9f70 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -104,9 +106,10 @@ class BaseAudioContext : public std::enable_shared_from_this { std::shared_ptr createWaveShaper(const WaveShaperOptions &options); std::shared_ptr getBasicWaveForm(OscillatorType type); - std::shared_ptr getGraphManager() const; + AudioGraphManager *getGraphManager() const; std::shared_ptr getAudioEventHandlerRegistry() const; const RuntimeRegistry &getRuntimeRegistry() const; + utils::DisposerImpl *getDisposer() const; virtual void initialize(); @@ -131,7 +134,6 @@ class BaseAudioContext : public std::enable_shared_from_this { private: std::atomic state_; std::atomic sampleRate_; - std::shared_ptr graphManager_; std::shared_ptr audioEventHandlerRegistry_; RuntimeRegistry runtimeRegistry_; @@ -142,6 +144,8 @@ class BaseAudioContext : public std::enable_shared_from_this { static constexpr size_t AUDIO_SCHEDULER_CAPACITY = 1024; CrossThreadEventScheduler audioEventScheduler_; + std::unique_ptr> disposer_; + std::unique_ptr graphManager_; [[nodiscard]] virtual bool isDriverRunning() const = 0; }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp index d2bf43911..58adf4441 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.cpp @@ -1,6 +1,5 @@ #include #include -#include #include #include #include @@ -27,7 +26,7 @@ ConvolverNode::ConvolverNode( void ConvolverNode::setBuffer( const std::shared_ptr &buffer, - std::vector convolvers, + std::vector> convolvers, const std::shared_ptr &threadPool, const std::shared_ptr &internalBuffer, const std::shared_ptr &intermediateBuffer, @@ -37,13 +36,25 @@ void ConvolverNode::setBuffer( return; } - auto graphManager = context->getGraphManager(); + if (buffer_ != nullptr) { + context->getDisposer()->dispose(std::move(buffer_)); + } + + if (threadPool_ != nullptr) { + context->getDisposer()->dispose(std::move(threadPool_)); + } - if (buffer_) { - graphManager->addAudioBufferForDestruction(std::move(buffer_)); + for (auto it = convolvers_.begin(); it != convolvers_.end(); ++it) { + context->getDisposer()->dispose(std::move(*it)); } - // TODO move convolvers, thread pool and DSPAudioBuffers destruction to graph manager as well + if (internalBuffer_ != nullptr) { + context->getDisposer()->dispose(std::move(internalBuffer_)); + } + + if (intermediateBuffer_ != nullptr) { + context->getDisposer()->dispose(std::move(intermediateBuffer_)); + } buffer_ = buffer; convolvers_ = std::move(convolvers); @@ -86,7 +97,7 @@ void ConvolverNode::onInputDisabled() { numberOfEnabledInputNodes_ -= 1; if (isEnabled() && numberOfEnabledInputNodes_ == 0) { signalledToStop_ = true; - remainingSegments_ = convolvers_.at(0).getSegCount(); + remainingSegments_ = convolvers_.at(0)->getSegCount(); } } @@ -144,7 +155,7 @@ void ConvolverNode::performConvolution(const std::shared_ptr &pr if (processingBuffer->getNumberOfChannels() == 1) { for (int i = 0; i < convolvers_.size(); ++i) { threadPool_->schedule([&, i] { - convolvers_[i].process( + convolvers_[i]->process( *processingBuffer->getChannel(0), *intermediateBuffer_->getChannel(i)); }); } @@ -160,7 +171,7 @@ void ConvolverNode::performConvolution(const std::shared_ptr &pr } for (int i = 0; i < convolvers_.size(); ++i) { threadPool_->schedule([this, i, inputChannelMap, outputChannelMap, &processingBuffer] { - convolvers_[i].process( + convolvers_[i]->process( *processingBuffer->getChannel(inputChannelMap[i]), *intermediateBuffer_->getChannel(outputChannelMap[i])); }); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h index 489fa191b..7f04e3d13 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/ConvolverNode.h @@ -27,7 +27,7 @@ class ConvolverNode : public AudioNode { /// @note Audio Thread only void setBuffer( const std::shared_ptr &buffer, - std::vector convolvers, + std::vector> convolvers, const std::shared_ptr &threadPool, const std::shared_ptr &internalBuffer, const std::shared_ptr &intermediateBuffer, @@ -58,7 +58,7 @@ class ConvolverNode : public AudioNode { // buffer to hold internal processed data std::shared_ptr internalBuffer_; // vectors of convolvers, one per channel - std::vector convolvers_; + std::vector> convolvers_; std::shared_ptr threadPool_; void performConvolution(const std::shared_ptr &processingBuffer); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp index 05abf41c5..14cbb63da 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferQueueSourceNode.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include #include @@ -78,10 +77,8 @@ void AudioBufferQueueSourceNode::dequeueBuffer(const size_t bufferId) { return; } - auto graphManager = context->getGraphManager(); - if (buffers_.front().first == bufferId) { - graphManager->addAudioBufferForDestruction(std::move(buffers_.front().second)); + context->getDisposer()->dispose(std::move(buffers_.front().second)); buffers_.pop_front(); vReadIndex_ = 0.0; return; @@ -91,7 +88,7 @@ void AudioBufferQueueSourceNode::dequeueBuffer(const size_t bufferId) { // And keep vReadIndex_ at the same position. for (auto it = std::next(buffers_.begin()); it != buffers_.end(); ++it) { if (it->first == bufferId) { - graphManager->addAudioBufferForDestruction(std::move(it->second)); + context->getDisposer()->dispose(std::move(it->second)); buffers_.erase(it); return; } @@ -102,7 +99,7 @@ void AudioBufferQueueSourceNode::dequeueBuffer(const size_t bufferId) { void AudioBufferQueueSourceNode::clearBuffers() { if (auto context = context_.lock()) { for (auto it = buffers_.begin(); it != buffers_.end(); ++it) { - context->getGraphManager()->addAudioBufferForDestruction(std::move(it->second)); + context->getDisposer()->dispose(std::move(it->second)); } buffers_.clear(); @@ -215,7 +212,7 @@ void AudioBufferQueueSourceNode::processWithoutInterpolation( buffers_.emplace_back(bufferId, tailBuffer_); addExtraTailFrames_ = false; } else { - context->getGraphManager()->addAudioBufferForDestruction(std::move(buffer)); + context->getDisposer()->dispose(std::move(buffer)); processingBuffer->zero(writeIndex, framesLeft); readIndex = 0; @@ -223,7 +220,7 @@ void AudioBufferQueueSourceNode::processWithoutInterpolation( } } - context->getGraphManager()->addAudioBufferForDestruction(std::move(buffer)); + context->getDisposer()->dispose(std::move(buffer)); data = buffers_.front(); bufferId = data.first; buffer = data.second; @@ -296,14 +293,14 @@ void AudioBufferQueueSourceNode::processWithInterpolation( sendOnBufferEndedEvent(bufferId, buffers_.empty()); if (buffers_.empty()) { - context->getGraphManager()->addAudioBufferForDestruction(std::move(buffer)); + context->getDisposer()->dispose(std::move(buffer)); processingBuffer->zero(writeIndex, framesLeft); vReadIndex_ = 0.0; break; } - context->getGraphManager()->addAudioBufferForDestruction(std::move(buffer)); vReadIndex_ = vReadIndex_ - buffer->getSize(); + context->getDisposer()->dispose(std::move(buffer)); data = buffers_.front(); bufferId = data.first; buffer = data.second; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp index e5bf7ec4a..acd85fa9b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include #include @@ -55,13 +54,17 @@ void AudioBufferSourceNode::setBuffer( return; } - auto graphManager = context->getGraphManager(); - if (buffer_ != nullptr) { - graphManager->addAudioBufferForDestruction(std::move(buffer_)); + context->getDisposer()->dispose(std::move(buffer_)); + } + + if (playbackRateBuffer_ != nullptr) { + context->getDisposer()->dispose(std::move(playbackRateBuffer_)); } - // TODO move DSPAudioBuffers destruction to graph manager as well + if (audioBuffer_ != nullptr) { + context->getDisposer()->dispose(std::move(audioBuffer_)); + } if (buffer == nullptr) { loopEnd_ = 0; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDestructor.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDestructor.hpp deleted file mode 100644 index 28f768993..000000000 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioDestructor.hpp +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace audioapi { - -/// @brief A generic class to offload object destruction to a separate thread. -/// @tparam T The type of object to be destroyed. -template -class AudioDestructor { - public: - AudioDestructor() : isExiting_(false) { - auto [sender, receiver] = channels::spsc::channel< - std::shared_ptr, - channels::spsc::OverflowStrategy::WAIT_ON_FULL, - channels::spsc::WaitStrategy::ATOMIC_WAIT>(kChannelCapacity); - sender_ = std::move(sender); - workerHandle_ = std::thread(&AudioDestructor::process, this, std::move(receiver)); - } - - ~AudioDestructor() { - isExiting_.store(true, std::memory_order_release); - - // We need to send a nullptr to unblock the receiver - sender_.send(nullptr); - if (workerHandle_.joinable()) { - workerHandle_.join(); - } - } - - /// @brief Adds an audio object to the deconstruction queue. - /// @param object The audio object to be deconstructed. - /// @return True if the node was successfully added, false otherwise. - /// @note audio object does NOT get moved out if it is not successfully added. - bool tryAddForDeconstruction(std::shared_ptr &&object) { - return sender_.try_send(std::move(object)) == channels::spsc::ResponseStatus::SUCCESS; - } - - private: - static constexpr size_t kChannelCapacity = 1024; - - std::thread workerHandle_; - std::atomic isExiting_; - - using SenderType = channels::spsc::Sender< - std::shared_ptr, - channels::spsc::OverflowStrategy::WAIT_ON_FULL, - channels::spsc::WaitStrategy::ATOMIC_WAIT>; - - using ReceiverType = channels::spsc::Receiver< - std::shared_ptr, - channels::spsc::OverflowStrategy::WAIT_ON_FULL, - channels::spsc::WaitStrategy::ATOMIC_WAIT>; - - SenderType sender_; - - /// @brief Processes audio objects for deconstruction. - /// @param receiver The receiver channel for incoming audio objects. - void process(ReceiverType &&receiver) { - auto rcv = std::move(receiver); - while (!isExiting_.load(std::memory_order_acquire)) { - rcv.receive(); - } - } -}; - -#undef AUDIO_NODE_DESTRUCTOR_SPSC_OPTIONS - -} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.cpp index 1689fc6b6..453171738 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -72,11 +73,11 @@ AudioGraphManager::Event::~Event() { } } -AudioGraphManager::AudioGraphManager() { +AudioGraphManager::AudioGraphManager(BaseAudioContext *context) + : disposer_(context->getDisposer()) { sourceNodes_.reserve(kInitialCapacity); processingNodes_.reserve(kInitialCapacity); audioParams_.reserve(kInitialCapacity); - audioBuffers_.reserve(kInitialCapacity); auto channel_pair = channels::spsc::channel< std::unique_ptr, @@ -119,9 +120,8 @@ void AudioGraphManager::addPendingParamConnection( void AudioGraphManager::preProcessGraph() { settlePendingConnections(); - AudioGraphManager::prepareForDestruction(sourceNodes_, nodeDestructor_); - AudioGraphManager::prepareForDestruction(processingNodes_, nodeDestructor_); - AudioGraphManager::prepareForDestruction(audioBuffers_, bufferDestructor_); + prepareForDestruction(sourceNodes_); + prepareForDestruction(processingNodes_); } void AudioGraphManager::addProcessingNode(const std::shared_ptr &node) { @@ -151,11 +151,6 @@ void AudioGraphManager::addAudioParam(const std::shared_ptr ¶m) sender_.send(std::move(event)); } -void AudioGraphManager::addAudioBufferForDestruction(std::shared_ptr buffer) { - // direct access because this is called from the Audio thread - audioBuffers_.emplace_back(std::move(buffer)); -} - void AudioGraphManager::settlePendingConnections() { std::unique_ptr value; while (receiver_.try_receive(value) != channels::spsc::ResponseStatus::CHANNEL_EMPTY) { @@ -234,7 +229,6 @@ void AudioGraphManager::cleanup() { sourceNodes_.clear(); processingNodes_.clear(); audioParams_.clear(); - audioBuffers_.clear(); } } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.h index 462fbddb0..7b51eed0b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/AudioGraphManager.h @@ -1,6 +1,7 @@ #pragma once -#include +#include +#include #include #include @@ -14,6 +15,7 @@ namespace audioapi { class AudioNode; class AudioScheduledSourceNode; class AudioParam; +class BaseAudioContext; #define AUDIO_GRAPH_MANAGER_SPSC_OPTIONS \ std::unique_ptr, channels::spsc::OverflowStrategy::WAIT_ON_FULL, \ @@ -59,7 +61,7 @@ class AudioGraphManager { ~Event(); }; - AudioGraphManager(); + explicit AudioGraphManager(BaseAudioContext *context); ~AudioGraphManager(); void preProcessGraph(); @@ -99,15 +101,10 @@ class AudioGraphManager { /// @note Should be only used from JavaScript/HostObjects thread void addAudioParam(const std::shared_ptr ¶m); - /// @brief Adds an audio buffer to the manager for destruction. - /// @note Called directly from the Audio thread (bypasses SPSC). - void addAudioBufferForDestruction(std::shared_ptr buffer); - void cleanup(); private: - AudioDestructor nodeDestructor_; - AudioDestructor bufferDestructor_; + utils::DisposerImpl *const disposer_; /// @brief Initial capacity for various node types for deletion /// @note Higher capacity decreases number of reallocations at runtime (can be easily adjusted to 128 if needed) @@ -120,10 +117,8 @@ class AudioGraphManager { std::vector> sourceNodes_; std::vector> processingNodes_; std::vector> audioParams_; - std::vector> audioBuffers_; channels::spsc::Receiver receiver_; - channels::spsc::Sender sender_; void settlePendingConnections(); @@ -154,11 +149,8 @@ class AudioGraphManager { return node.use_count() == 1; } - template - requires std::convertible_to - static void prepareForDestruction( - std::vector> &vec, - AudioDestructor &audioDestructor) { + template + void prepareForDestruction(std::vector> &vec) { if (vec.empty()) { return; } @@ -201,7 +193,7 @@ class AudioGraphManager { /// If we fail to add we can't safely remove the node from the vector /// so we swap it and advance begin cursor /// @note vec[i] does NOT get moved out if it is not successfully added. - if (!audioDestructor.tryAddForDeconstruction(std::move(vec[i]))) { + if (!disposer_->dispose(std::move(vec[i]))) { std::swap(vec[i], vec[begin]); begin++; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h index 79a9a3f69..319e0067a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Constants.h @@ -30,6 +30,9 @@ inline float LOG2_MOST_POSITIVE_SINGLE_FLOAT = std::log2(MOST_POSITIVE_SINGLE_FL inline float LOG10_MOST_POSITIVE_SINGLE_FLOAT = std::log10(MOST_POSITIVE_SINGLE_FLOAT); inline constexpr float PI = std::numbers::pi_v; +// disposer +inline constexpr size_t DISPOSER_PAYLOAD_SIZE = 16; + // buffer sizes inline constexpr size_t PROMISE_VENDOR_THREAD_POOL_WORKER_COUNT = 4; inline constexpr size_t PROMISE_VENDOR_THREAD_POOL_LOAD_BALANCER_QUEUE_SIZE = 32; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Disposer.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Disposer.hpp similarity index 95% rename from packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Disposer.hpp rename to packages/react-native-audio-api/common/cpp/audioapi/core/utils/Disposer.hpp index 7b2e6f030..6a1883f54 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Disposer.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/Disposer.hpp @@ -4,12 +4,11 @@ #include #include -#include #include #include #include -namespace audioapi::utils::graph { +namespace audioapi::utils { /// @brief A disposal payload that can hold any trivially-relocatable or /// move-constructible type up to N bytes. The value is moved into a raw byte @@ -22,7 +21,7 @@ struct DisposalPayload { void (*destructor)(void *); // type-erased destructor /// @brief Sentinel check — a null destructor means "shutdown". - bool isSentinel() const; + [[nodiscard]] bool isSentinel() const; /// @brief Creates a sentinel payload used to signal worker thread shutdown. static DisposalPayload sentinel(); @@ -139,9 +138,9 @@ DisposerImpl::DisposerImpl(size_t channelCapacity) { auto [sender, receiver] = channel(channelCapacity); sender_ = std::move(sender); - workerHandle_ = std::thread([receiver = std::move(receiver)]() mutable { + workerHandle_ = std::thread([receiver_ = std::move(receiver)]() mutable { while (true) { - auto payload = receiver.receive(); + auto payload = receiver_.receive(); if (payload.isSentinel()) { break; } @@ -163,4 +162,4 @@ bool DisposerImpl::doDispose(DisposalPayload &&payload) { return sender_.try_send(std::move(payload)) == audioapi::channels::spsc::ResponseStatus::SUCCESS; } -} // namespace audioapi::utils::graph +} // namespace audioapi::utils diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.hpp index 1d56c9322..9a2b98295 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.hpp @@ -1,12 +1,10 @@ #pragma once -#include #include #include #include #include -#include #include #include #include @@ -16,9 +14,6 @@ namespace audioapi::utils::graph { -template -concept AudioGraphNode = std::derived_from; - /// @brief Cache-friendly, index-stable node storage with in-place topological sort. /// /// Nodes are stored in a flat vector that is kept topologically sorted @@ -26,16 +21,15 @@ concept AudioGraphNode = std::derived_from; /// orphaned nodes and O(1)-extra-space Kahn's toposort. /// /// @note Can store at most 2^30 nodes due to bit-packed indices (~10^9). -template class AudioGraph { // ── Node ──────────────────────────────────────────────────────────────── struct Node { Node() = default; - explicit Node(std::shared_ptr> handle) : handle(handle) {} + explicit Node(std::shared_ptr handle) : handle(handle) {} - std::shared_ptr> handle = nullptr; // owned handle bridging to HostGraph - std::uint32_t input_head = InputPool::kNull; // head of input linked list in pool_ + std::shared_ptr handle = nullptr; // owned handle bridging to HostGraph + std::uint32_t input_head = InputPool::kNull; // head of input linked list in pool_ std::uint32_t topo_out_degree : 31 = 0; // scratch — Kahn's out-degree counter unsigned will_be_deleted : 1 = 0; // scratch — marked for compaction removal @@ -60,10 +54,10 @@ class AudioGraph { AudioGraph(AudioGraph &&) noexcept = default; AudioGraph &operator=(AudioGraph &&) noexcept = default; - /// @brief Entry returned by iter() — a reference to the audio node and a view of its inputs. + /// @brief Entry returned by iter() — a reference to the graph object and a view of its inputs. template struct Entry { - NodeType &audioNode; + GraphObject &graphObject; InputsView inputs; }; @@ -83,13 +77,13 @@ class AudioGraph { /// @brief Provides an iterable view of the nodes in topological order. /// - /// Each entry contains a reference to the AudioNode and an immutable view - /// of its inputs (as references to AudioNodes). + /// Each entry contains a reference to the GraphObject and an immutable view + /// of its inputs (as references to GraphObject). /// /// ## Example usage: /// ```cpp - /// for (auto [audioNode, inputs] : graph.iter()) { - /// // process audioNode and its inputs + /// for (auto [graphObject, inputs] : graph.iter()) { + /// // process graphObject and its inputs /// } /// ``` /// @note Lifetime of entries is bound to this graph — they are not owned. @@ -115,14 +109,14 @@ class AudioGraph { /// @brief Adds a new node. AudioGraph takes shared ownership of the handle. /// @param handle shared NodeHandle bridging to HostGraph - void addNode(std::shared_ptr> handle); + void addNode(std::shared_ptr handle); /// @brief Recomputes topological order (if dirty), then compacts the graph /// by removing orphaned, input-free, destructible nodes. /// /// When a node is compacted out its `shared_ptr` is released /// (refcount drops 2 → 1). HostGraph detects this via `use_count() == 1` - /// and destroys the ghost + AudioNode on the main thread. + /// and destroys the ghost + GraphObject on the main thread. /// /// Uses a two-pass approach: pass 1 marks deletions (cascading in topo /// order) and computes index remapping; pass 2 remaps inputs and shifts @@ -155,68 +149,59 @@ class AudioGraph { // ── Accessors ───────────────────────────────────────────────────────────── -template -auto AudioGraph::operator[](std::uint32_t index) -> Node & { +inline auto AudioGraph::operator[](std::uint32_t index) -> Node & { return nodes[index]; } -template -auto AudioGraph::operator[](std::uint32_t index) const -> const Node & { +inline auto AudioGraph::operator[](std::uint32_t index) const -> const Node & { return nodes[index]; } -template -size_t AudioGraph::size() const { +inline size_t AudioGraph::size() const { return nodes.size(); } -template -bool AudioGraph::empty() const { +inline bool AudioGraph::empty() const { return nodes.empty(); } -template -auto AudioGraph::iter() { - return nodes | std::views::transform([this](Node &node) { +inline auto AudioGraph::iter() { + return nodes | + std::views::filter([](const Node &n) { return n.handle->audioNode->isProcessable(); }) | + std::views::transform([this](Node &node) { return Entry{ *node.handle->audioNode, pool_.view(node.input_head) | - std::views::transform([this](std::uint32_t idx) -> const NodeType & { + std::views::transform([this](std::uint32_t idx) -> const GraphObject & { return *nodes[idx].handle->audioNode; })}; }); } -template -InputPool &AudioGraph::pool() { +inline InputPool &AudioGraph::pool() { return pool_; } -template -const InputPool &AudioGraph::pool() const { +inline const InputPool &AudioGraph::pool() const { return pool_; } -template -void AudioGraph::reserveNodes(std::uint32_t capacity) { +inline void AudioGraph::reserveNodes(std::uint32_t capacity) { nodes.reserve(capacity); } // ── Mutators ────────────────────────────────────────────────────────────── -template -void AudioGraph::markDirty() { +inline void AudioGraph::markDirty() { topo_order_dirty = true; } -template -void AudioGraph::addNode(std::shared_ptr> handle) { +inline void AudioGraph::addNode(std::shared_ptr handle) { handle->index = static_cast(nodes.size()); nodes.emplace_back(std::move(handle)); } -template -void AudioGraph::process() { +inline void AudioGraph::process() { if (topo_order_dirty) { topo_order_dirty = false; kahn_toposort(); @@ -293,8 +278,7 @@ void AudioGraph::process() { // ── Kahn's toposort ─────────────────────────────────────────────────────── -template -void AudioGraph::kahn_toposort() { +inline void AudioGraph::kahn_toposort() { const auto n = static_cast(nodes.size()); if (n <= 1) return; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/BridgeNode.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/BridgeNode.hpp new file mode 100644 index 000000000..d373977a2 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/BridgeNode.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include + +namespace audioapi { +class AudioParam; +} + +namespace audioapi::utils::graph { + +/// @brief Lightweight graph-only node that represents an AudioParam connection. +/// +/// A BridgeNode sits between a source AudioNode and the owner AudioNode of a +/// param, forming the path: source → bridge → owner. This lets the graph +/// system detect cycles and compute correct topological ordering for param +/// connections without creating real ownership dependencies. +/// +/// BridgeNodes are: +/// - **Not processable** — skipped by `AudioGraph::iter()`. +/// - **Always destructible** — removed by compaction when orphaned with no inputs. +/// - **Non-owning** — stores a raw `AudioParam*` whose lifetime is guaranteed +/// by the owner node. +class BridgeNode final : public GraphObject { + public: + explicit BridgeNode(AudioParam *param) : param_(param) {} + + [[nodiscard]] bool isProcessable() const override { + return false; + } + + [[nodiscard]] bool canBeDestructed() const override { + return true; + } + + /// @brief Returns the param this bridge represents a connection to. + [[nodiscard]] AudioParam *param() const { + return param_; + } + + private: + AudioParam *param_; // non-owning — lifetime guaranteed by owner node +}; + +} // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.hpp index 269f32476..c272bc315 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.hpp @@ -1,7 +1,8 @@ #pragma once +#include #include -#include +#include #include #include @@ -13,8 +14,13 @@ #include #include #include +#include #include +namespace audioapi { +class AudioParam; +} + namespace audioapi::utils::graph { /// @brief Thread-safe graph coordinator that bridges HostGraph (main thread) @@ -32,9 +38,8 @@ namespace audioapi::utils::graph { /// graph.process(); // toposort + compaction /// for (auto&& [node, inputs] : graph.iter()) { ... } /// ``` -template class Graph { - using AGEvent = HostGraph::AGEvent; + using AGEvent = HostGraph::AGEvent; // ── Event channel (main → audio): grow + graph mutations ─────────────── @@ -47,13 +52,14 @@ class Graph { audioapi::channels::spsc::OverflowStrategy::WAIT_ON_FULL, audioapi::channels::spsc::WaitStrategy::BUSY_LOOP>; - using HNode = HostGraph::Node; + using HNode = HostGraph::Node; public: - using ResultError = HostGraph::ResultError; + static constexpr size_t kDisposerPayloadSize = HostGraph::kDisposerPayloadSize; + using ResultError = HostGraph::ResultError; using Res = Result; - explicit Graph(size_t eventQueueCapacity) { + Graph(size_t eventQueueCapacity, Disposer *disposer) : disposer_(disposer) { using namespace audioapi::channels::spsc; auto [es, er] = channel( @@ -64,9 +70,10 @@ class Graph { Graph( size_t eventQueueCapacity, + Disposer *disposer, std::uint32_t initialNodeCapacity, std::uint32_t initialEdgeCapacity) - : Graph(eventQueueCapacity) { + : Graph(eventQueueCapacity, disposer) { if (initialNodeCapacity > 0) { audioGraph.reserveNodes(initialNodeCapacity); nodeCapacity_ = initialNodeCapacity; @@ -92,7 +99,7 @@ class Graph { AGEvent event; while (eventReceiver_.try_receive(event) == audioapi::channels::spsc::ResponseStatus::SUCCESS) { if (event) { - event(audioGraph, disposer_); + event(audioGraph, *disposer_); } } } @@ -106,8 +113,8 @@ class Graph { /// @brief Returns an iterable view of nodes in topological order. /// - /// Each entry contains a reference to the NodeType and an immutable view - /// of its inputs (as references to NodeType). + /// Each entry contains a reference to GraphObject and an immutable view + /// of its inputs (as references to GraphObject). /// Allocation-free. /// /// @note Should be called only from the audio thread, after process(). @@ -120,10 +127,10 @@ class Graph { /// @brief Adds a new node to the graph and returns a pointer to it. /// @param audioNode the audio processing node to add (ownership transferred) /// @return pointer to the newly added HostGraph::Node - HNode *addNode(std::unique_ptr audioNode = nullptr) { + HNode *addNode(std::unique_ptr audioNode = nullptr) { hostGraph.collectDisposedNodes(); - auto handle = std::make_shared>(0, std::move(audioNode)); + auto handle = std::make_shared(0, std::move(audioNode)); auto [hostNode, event] = hostGraph.addNode(handle); sendNodeGrowIfNeeded(); @@ -132,6 +139,11 @@ class Graph { return hostNode; } + template >> + HNode *addNode(std::unique_ptr audioNode) { + return addNode(std::unique_ptr(std::move(audioNode))); + } + /// @brief Removes a node (marks as ghost). Pointer remains valid until /// the ghost is collected after AudioGraph releases its shared_ptr. Res removeNode(HNode *node) { @@ -161,14 +173,121 @@ class Graph { }); } - private: - static constexpr size_t kDisposerPayloadSize = HostGraph::kDisposerPayloadSize; + // ── Param bridge API ─────────────────────────────────────────────────── + + /// @brief Creates a bridge node representing: source → bridge → owner. + /// + /// The bridge encodes a param connection in the graph for cycle detection + /// and topological ordering. The bridge itself is not processable. + /// + /// @param source the node whose output feeds the param + /// @param owner the node that owns the param + /// @param param raw pointer to the AudioParam (lifetime guaranteed by owner) + /// @return Ok on success, Err on cycle/duplicate/not-found + Res connectParam(HNode *source, HNode *owner, AudioParam *param) { + hostGraph.collectDisposedNodes(); + + BridgeKey key{source, param}; + if (bridgeMap_.count(key)) { + return Res::Err(ResultError::EDGE_ALREADY_EXISTS); + } + + // Create bridge node + auto bridgeObj = std::make_unique(param); + auto bridgeHandle = std::make_shared(0, std::move(bridgeObj)); + auto [bridgeHostNode, addEvent] = hostGraph.addNode(bridgeHandle); + + // source → bridge + auto edgeRes1 = hostGraph.addEdge(source, bridgeHostNode); + if (edgeRes1.is_err()) { + // Rollback: remove bridge node + (void)hostGraph.removeNode(bridgeHostNode); + return Res::Err(edgeRes1.unwrap_err()); + } + + // bridge → owner + auto edgeRes2 = hostGraph.addEdge(bridgeHostNode, owner); + if (edgeRes2.is_err()) { + // Rollback: remove source→bridge edge and bridge node + (void)hostGraph.removeEdge(source, bridgeHostNode); + (void)hostGraph.removeNode(bridgeHostNode); + return Res::Err(edgeRes2.unwrap_err()); + } + + // All succeeded — send events through SPSC + sendNodeGrowIfNeeded(); + eventSender_.send(std::move(addEvent)); + + sendPoolGrowIfNeeded(); + eventSender_.send(std::move(edgeRes1).unwrap()); + + sendPoolGrowIfNeeded(); + eventSender_.send(std::move(edgeRes2).unwrap()); + + // Track bridge + bridgeMap_[key] = bridgeHostNode; + bridgeOwners_[bridgeHostNode] = owner; + + return Res::Ok(NoneType{}); + } + + /// @brief Removes a bridge node for the given (source, param) pair. + Res disconnectParam(HNode *source, HNode * /*owner*/, AudioParam *param) { + hostGraph.collectDisposedNodes(); + + BridgeKey key{source, param}; + auto it = bridgeMap_.find(key); + if (it == bridgeMap_.end()) { + return Res::Err(ResultError::EDGE_NOT_FOUND); + } + + HNode *bridge = it->second; + removeBridge(source, bridge); + bridgeMap_.erase(it); + + return Res::Ok(NoneType{}); + } + /// @brief Removes a node and cascade-removes any bridges where this node + /// is the source or owner. + Res removeNodeWithBridges(HNode *node) { + hostGraph.collectDisposedNodes(); + + // Cascade: remove bridges where this node is source + for (auto it = bridgeMap_.begin(); it != bridgeMap_.end();) { + if (it->first.source == node) { + HNode *bridge = it->second; + removeBridge(node, bridge); + bridgeOwners_.erase(bridge); + it = bridgeMap_.erase(it); + } else { + ++it; + } + } + + // Cascade: remove bridges where this node is owner + for (auto it = bridgeMap_.begin(); it != bridgeMap_.end();) { + auto ownerIt = bridgeOwners_.find(it->second); + if (ownerIt != bridgeOwners_.end() && ownerIt->second == node) { + HNode *bridge = it->second; + HNode *source = it->first.source; + removeBridge(source, bridge); + bridgeOwners_.erase(ownerIt); + it = bridgeMap_.erase(it); + } else { + ++it; + } + } + + return removeNode(node); + } + + private: using OwnedSlotBuffer = std::unique_ptr; // Aligning to cache line size to prevent false sharing between audio and main thread - alignas(hardware_destructive_interference_size) AudioGraph audioGraph; - alignas(hardware_destructive_interference_size) HostGraph hostGraph; + alignas(hardware_destructive_interference_size) AudioGraph audioGraph; + alignas(hardware_destructive_interference_size) HostGraph hostGraph; // ── Channel (immutable after construction — no false sharing) ─────────── @@ -177,7 +296,7 @@ class Graph { // ── Disposer — destroys old pool buffers off the audio thread ─────────── - DisposerImpl disposer_{64}; + Disposer *disposer_; // ── Main-thread tracking for pre-growth ───────────────────────────────── @@ -196,14 +315,13 @@ class Graph { if (edges > poolCapacity_ / 2) { std::uint32_t newCap = std::max(static_cast(edges * 2), std::uint32_t{64}); auto buf = std::make_unique(newCap); - eventSender_.send( - [buf = std::move(buf), newCap]( - AudioGraph &graph, Disposer &disposer) mutable { - auto *old = graph.pool().adoptBuffer(buf.release(), newCap); - if (old) { - disposer.dispose(OwnedSlotBuffer(old)); - } - }); + eventSender_.send([buf = std::move(buf), newCap]( + AudioGraph &graph, Disposer &disposer) mutable { + auto *old = graph.pool().adoptBuffer(buf.release(), newCap); + if (old) { + disposer.dispose(OwnedSlotBuffer(old)); + } + }); poolCapacity_ = newCap; } } @@ -215,12 +333,62 @@ class Graph { auto nodes = static_cast(hostGraph.nodeCount()); if (nodes > nodeCapacity_) { std::uint32_t newCap = std::max(static_cast(nodes * 2), std::uint32_t{64}); - eventSender_.send( - [newCap](AudioGraph &graph, auto &) { graph.reserveNodes(newCap); }); + eventSender_.send([newCap](AudioGraph &graph, auto &) { graph.reserveNodes(newCap); }); nodeCapacity_ = newCap; } } + // ── Bridge tracking (main thread only) ────────────────────────────────── + + struct BridgeKey { + HNode *source; + AudioParam *param; + + bool operator==(const BridgeKey &other) const { + return source == other.source && param == other.param; + } + }; + + struct BridgeKeyHash { + size_t operator()(const BridgeKey &k) const { + auto h1 = std::hash{}(k.source); + auto h2 = std::hash{}(k.param); + return h1 ^ (h2 << 1); + } + }; + + /// Maps (source, param) → bridge host node + std::unordered_map bridgeMap_; + + /// Maps bridge host node → owner host node (for cascade removal) + std::unordered_map bridgeOwners_; + + /// @brief Removes a bridge node: tears down edges and marks for removal. + void removeBridge(HNode *source, HNode *bridge) { + // Find the owner from bridgeOwners_ + auto ownerIt = bridgeOwners_.find(bridge); + HNode *owner = (ownerIt != bridgeOwners_.end()) ? ownerIt->second : nullptr; + + // Remove edges: source→bridge, bridge→owner + auto res1 = hostGraph.removeEdge(source, bridge); + if (res1.is_ok()) { + eventSender_.send(std::move(res1).unwrap()); + } + + if (owner) { + auto res2 = hostGraph.removeEdge(bridge, owner); + if (res2.is_ok()) { + eventSender_.send(std::move(res2).unwrap()); + } + } + + // Remove bridge node + auto res3 = hostGraph.removeNode(bridge); + if (res3.is_ok()) { + eventSender_.send(std::move(res3).unwrap()); + } + } + friend class GraphTest; }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/GraphObject.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/GraphObject.hpp new file mode 100644 index 000000000..2363af77c --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/GraphObject.hpp @@ -0,0 +1,63 @@ +#pragma once + +namespace audioapi { +class AudioNode; +class AudioParam; +} // namespace audioapi + +namespace audioapi::utils::graph { + +/// @brief Base class for graph objects (AudioNode or AudioParam). +/// GraphObjects are owned by NodeHandles and stored in AudioGraph's flat vector +/// +/// ## Lifecycle +/// - Created on the main thread as a unique_ptr +/// - Transferred to AudioGraph via NodeHandle on node addition +/// - Accessed on the audio thread during processing (e.g. for processAudio) +/// - Destroyed when all below conditions are met: +/// 1. The HostNode is removed and the NodeHandle is marked as a ghost +/// 2. The Node has no inputs +/// 3. canBeDestructed() returns true (e.g. AudioNode-specific lifecycle checks) +class GraphObject { + public: + virtual ~GraphObject() = default; + + /// @brief Returns whether this graph object can be safely destroyed. + /// + /// Default behavior is permissive for new GraphObject-based entities. + /// AudioNode / AudioParam can override with richer lifecycle checks. + [[nodiscard]] virtual bool canBeDestructed() const { + return true; + } + + /// @brief Returns whether this node should be processed during audio iteration. + /// + /// Default is true. BridgeNodes override to return false — they exist only + /// for graph structure (cycle detection, topo ordering) and are skipped + /// by AudioGraph::iter(). + [[nodiscard]] virtual bool isProcessable() const { + return true; + } + + /// @brief Downcast helper for node-specific handling. + [[nodiscard]] virtual AudioNode *asAudioNode() { + return nullptr; + } + + /// @brief Downcast helper for node-specific handling. + [[nodiscard]] virtual const AudioNode *asAudioNode() const { + return nullptr; + } + + /// @brief Downcast helper for param-specific handling. + [[nodiscard]] virtual AudioParam *asAudioParam() { + return nullptr; + } + + /// @brief Downcast helper for param-specific handling. + [[nodiscard]] virtual const AudioParam *asAudioParam() const { + return nullptr; + } +}; + +} // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.hpp index c785248a5..d1b2ff2a1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.hpp @@ -1,7 +1,7 @@ #pragma once +#include #include -#include #include #include #include @@ -15,9 +15,7 @@ class GraphCycleDebugTest; namespace audioapi::utils::graph { -template class HostGraph; -template class Graph; class TestGraphUtils; @@ -33,7 +31,6 @@ class TestGraphUtils; /// shared_ptr (detected via `use_count() == 1`). /// /// @note Use through the Graph wrapper for safety. -template class HostGraph { public: enum class ResultError { @@ -48,7 +45,7 @@ class HostGraph { /// Event that modifies AudioGraph to keep it consistent with HostGraph. /// The second argument is the Disposer used to offload buffer deallocation. - using AGEvent = FatFunction<32, void(AudioGraph &, Disposer &)>; + using AGEvent = FatFunction<32, void(AudioGraph &, Disposer &)>; using Res = Result; @@ -65,7 +62,7 @@ class HostGraph { std::vector inputs; // reversed edges std::vector outputs; // forward edges TraversalState traversalState; - std::shared_ptr> handle; // shared handle bridging to AudioGraph + std::shared_ptr handle; // shared handle bridging to AudioGraph bool ghost = false; // kept for cycle detection until AudioGraph confirms deletion #if RN_AUDIO_API_TEST @@ -92,7 +89,7 @@ class HostGraph { /// @brief Adds a new node to the graph. /// @param handle shared handle that bridges HostGraph ↔ AudioGraph /// @return pair of (raw Node pointer, AGEvent to replay on AudioGraph) - std::pair addNode(std::shared_ptr> handle); + std::pair addNode(std::shared_ptr handle); /// @brief Removes a node (marks it as ghost, keeps edges for cycle detection). /// @return AGEvent that sets `orphaned = true` on the AudioGraph side. @@ -124,7 +121,7 @@ class HostGraph { /// `use_count() == 1`, meaning AudioGraph has released its reference. void collectDisposedNodes(); - friend class Graph; + friend class Graph; friend class TestGraphUtils; friend class HostGraphTest; friend class GraphCycleDebugTest; @@ -134,8 +131,7 @@ class HostGraph { // Implementation // ========================================================================= -template -bool HostGraph::TraversalState::visit(size_t currentTerm) { +inline bool HostGraph::TraversalState::visit(size_t currentTerm) { if (term == currentTerm) { return false; } @@ -143,8 +139,7 @@ bool HostGraph::TraversalState::visit(size_t currentTerm) { return true; } -template -HostGraph::Node::~Node() { +inline HostGraph::Node::~Node() { for (Node *input : inputs) { auto &outs = input->outputs; outs.erase(std::remove(outs.begin(), outs.end(), this), outs.end()); @@ -157,23 +152,20 @@ HostGraph::Node::~Node() { // ── Lifecycle ───────────────────────────────────────────────────────────── -template -HostGraph::~HostGraph() { +inline HostGraph::~HostGraph() { for (Node *n : nodes) { delete n; } nodes.clear(); } -template -HostGraph::HostGraph(HostGraph &&other) noexcept +inline HostGraph::HostGraph(HostGraph &&other) noexcept : nodes(std::move(other.nodes)), edgeCount_(other.edgeCount_), last_term(other.last_term) { other.edgeCount_ = 0; other.last_term = 0; } -template -auto HostGraph::operator=(HostGraph &&other) noexcept -> HostGraph & { +inline auto HostGraph::operator=(HostGraph &&other) noexcept -> HostGraph & { if (this != &other) { for (Node *n : nodes) { delete n; @@ -187,9 +179,7 @@ auto HostGraph::operator=(HostGraph &&other) noexcept -> HostGraph & { return *this; } -template -auto HostGraph::addNode(std::shared_ptr> handle) - -> std::pair { +inline auto HostGraph::addNode(std::shared_ptr handle) -> std::pair { Node *newNode = new Node(); newNode->handle = handle; nodes.push_back(newNode); @@ -201,8 +191,7 @@ auto HostGraph::addNode(std::shared_ptr> handle) return {newNode, std::move(event)}; } -template -auto HostGraph::removeNode(Node *node) -> Res { +inline auto HostGraph::removeNode(Node *node) -> Res { auto it = std::find(nodes.begin(), nodes.end(), node); if (it == nodes.end()) { return Res::Err(ResultError::NODE_NOT_FOUND); @@ -211,11 +200,10 @@ auto HostGraph::removeNode(Node *node) -> Res { node->ghost = true; return Res::Ok( - [h = node->handle](AudioGraph &graph, auto &) { graph[h->index].orphaned = true; }); + [h = node->handle](AudioGraph &graph, auto &) { graph[h->index].orphaned = true; }); } -template -auto HostGraph::addEdge(Node *from, Node *to) -> Res { +inline auto HostGraph::addEdge(Node *from, Node *to) -> Res { if (std::find(nodes.begin(), nodes.end(), from) == nodes.end() || std::find(nodes.begin(), nodes.end(), to) == nodes.end()) { return Res::Err(ResultError::NODE_NOT_FOUND); @@ -237,14 +225,13 @@ auto HostGraph::addEdge(Node *from, Node *to) -> Res { to->inputs.push_back(from); edgeCount_++; - return Res::Ok([hFrom = from->handle, hTo = to->handle](AudioGraph &graph, auto &) { + return Res::Ok([hFrom = from->handle, hTo = to->handle](AudioGraph &graph, auto &) { graph.pool().push(graph[hTo->index].input_head, hFrom->index); graph.markDirty(); }); } -template -auto HostGraph::removeEdge(Node *from, Node *to) -> Res { +inline auto HostGraph::removeEdge(Node *from, Node *to) -> Res { if (std::find(nodes.begin(), nodes.end(), from) == nodes.end() || std::find(nodes.begin(), nodes.end(), to) == nodes.end()) { return Res::Err(ResultError::NODE_NOT_FOUND); @@ -265,14 +252,13 @@ auto HostGraph::removeEdge(Node *from, Node *to) -> Res { from->outputs.erase(itOut); edgeCount_--; - return Res::Ok([hFrom = from->handle, hTo = to->handle](AudioGraph &graph, auto &) { + return Res::Ok([hFrom = from->handle, hTo = to->handle](AudioGraph &graph, auto &) { graph.pool().remove(graph[hTo->index].input_head, hFrom->index); graph.markDirty(); }); } -template -bool HostGraph::hasPath(Node *start, Node *end) { +inline bool HostGraph::hasPath(Node *start, Node *end) { if (start == end) { return true; } @@ -301,18 +287,15 @@ bool HostGraph::hasPath(Node *start, Node *end) { return false; } -template -size_t HostGraph::edgeCount() const { +inline size_t HostGraph::edgeCount() const { return edgeCount_; } -template -size_t HostGraph::nodeCount() const { +inline size_t HostGraph::nodeCount() const { return nodes.size(); } -template -void HostGraph::collectDisposedNodes() { +inline void HostGraph::collectDisposedNodes() { for (auto it = nodes.begin(); it != nodes.end();) { Node *n = *it; if (n->ghost && n->handle.use_count() == 1) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.hpp index e591ac848..2b1b66e5a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostNode.hpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -9,13 +10,13 @@ namespace audioapi::utils::graph { /// @brief RAII base class for host-side nodes. /// -/// Holds a `shared_ptr>` to keep the graph alive and owns a +/// Holds a `shared_ptr` to keep the graph alive and owns a /// `HostGraph::Node*` managed by that graph. On construction the node is /// registered in the graph (and an event is sent to AudioGraph); on /// destruction the node is removed (scheduling orphan-marking on AudioGraph). /// /// Host objects that represent audio processing nodes should publicly inherit -/// from HostNode and pass their payload (the AudioNode-like object) to the +/// from HostNode and pass their payload (GraphObject-derived object) to the /// constructor. `connect` / `disconnect` provide edge management. /// /// @note HostNode intentionally does NOT prevent cycles — callers must handle @@ -23,9 +24,9 @@ namespace audioapi::utils::graph { /// /// ## Example usage: /// ```cpp -/// class MyGainNode : public HostNode { +/// class MyGainNode : public HostNode { /// public: -/// MyGainNode(std::shared_ptr> g, +/// MyGainNode(std::shared_ptr g, /// std::unique_ptr impl) /// : HostNode(std::move(g), std::move(impl)) {} /// }; @@ -34,21 +35,27 @@ namespace audioapi::utils::graph { /// gain->connect(*destination); /// gain.reset(); // destructor removes the node from the graph /// ``` -template class HostNode { public: - using GraphType = Graph; - using HNode = HostGraph::Node; - using ResultError = HostGraph::ResultError; + using GraphType = Graph; + using HNode = HostGraph::Node; + using ResultError = HostGraph::ResultError; using Res = Result; /// @brief Constructs a HostNode, adding it to the graph. /// @param graph shared ownership of the Graph — prevents the graph from /// being destroyed while any HostNode still references it - /// @param audioNode the audio processing payload (ownership transferred - /// through to AudioGraph via NodeHandle) - explicit HostNode(std::shared_ptr graph, std::unique_ptr audioNode = nullptr) - : graph_(std::move(graph)), node_(graph_->addNode(std::move(audioNode))) {} + /// @param graphObject the payload (ownership transferred through to + /// AudioGraph via NodeHandle) + explicit HostNode( + std::shared_ptr graph, + std::unique_ptr graphObject = nullptr) + : graph_(std::move(graph)), node_(graph_->addNode(std::move(graphObject))) {} + + template + requires std::derived_from + explicit HostNode(std::shared_ptr graph, std::unique_ptr graphObject) + : HostNode(std::move(graph), std::unique_ptr(std::move(graphObject))) {} /// @brief Destructor removes the node from the graph. /// This marks the node as a ghost in HostGraph, and schedules an event @@ -96,6 +103,18 @@ class HostNode { return graph_->removeEdge(node_, other.node_); } + /// @brief Connects this node's output to a param on the owner node via a bridge. + /// @return Ok on success, Err on cycle / duplicate / not-found + Res connectParam(HostNode &owner, AudioParam *param) { + return graph_->connectParam(node_, owner.node_, param); + } + + /// @brief Disconnects this node's output from a param on the owner node. + /// @return Ok on success, Err on not-found + Res disconnectParam(HostNode &owner, AudioParam *param) { + return graph_->disconnectParam(node_, owner.node_, param); + } + /// @brief Returns the raw HostGraph::Node pointer (for advanced usage / testing). [[nodiscard]] HNode *rawNode() const { return node_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/NodeHandle.hpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/NodeHandle.hpp index 6765677c5..5c5fc1a1e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/NodeHandle.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/NodeHandle.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -20,12 +22,11 @@ namespace audioapi::utils::graph { /// - When AudioGraph compacts out an orphaned node it releases its shared_ptr /// (refcount 2 → 1). HostGraph detects use_count() == 1 and destroys the /// ghost + payload on the main thread. -template struct NodeHandle { - std::uint32_t index; // current position in AudioGraph::nodes - std::unique_ptr audioNode; // the payload node (may be null in tests) + std::unique_ptr audioNode; // payload graph object (may be null in tests) + std::uint32_t index; // current position in AudioGraph::nodes - NodeHandle(std::uint32_t index, std::unique_ptr audioNode) + NodeHandle(std::uint32_t index, std::unique_ptr audioNode) : index(index), audioNode(std::move(audioNode)) {} }; diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp index 941ca6296..8e679116e 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace audioapi::utils::graph { @@ -24,11 +25,11 @@ class AudioGraphFuzzTest : public ::testing::TestWithParam { protected: using MNode = MockNode; - AudioGraph graph; + AudioGraph graph; std::mt19937_64 rng; // Track live handles so we can reference them - std::vector>> handles; + std::vector> handles; size_t nextId = 0; void SetUp() override { @@ -37,8 +38,9 @@ class AudioGraphFuzzTest : public ::testing::TestWithParam { // ── Helpers ───────────────────────────────────────────────────────────── - std::shared_ptr> doAddNode() { - auto h = std::make_shared>(0, std::make_unique()); + std::shared_ptr doAddNode() { + std::unique_ptr obj = std::make_unique(); + auto h = std::make_shared(0, std::move(obj)); graph.addNode(h); graph[h->index].test_node_identifier__ = nextId++; handles.push_back(h); @@ -52,7 +54,7 @@ class AudioGraphFuzzTest : public ::testing::TestWithParam { // pickLive() will skip it because it checks orphaned status. } - void doAddEdge(std::shared_ptr> &from, std::shared_ptr> &to) { + void doAddEdge(std::shared_ptr &from, std::shared_ptr &to) { auto fromIdx = from->index; auto toIdx = to->index; // Verify at point-of-add that this edge doesn't create a duplicate @@ -67,9 +69,7 @@ class AudioGraphFuzzTest : public ::testing::TestWithParam { graph.markDirty(); } - void doRemoveEdge( - std::shared_ptr> &from, - std::shared_ptr> &to) { + void doRemoveEdge(std::shared_ptr &from, std::shared_ptr &to) { // Same as what HostGraph's removeEdge event does graph.pool().remove(graph[to->index].input_head, from->index); graph.markDirty(); diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp index e99780ed6..e21c32b47 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp @@ -14,26 +14,25 @@ namespace audioapi::utils::graph { // --------------------------------------------------------------------------- class AudioGraphTest : public ::testing::Test { protected: - AudioGraph graph; + AudioGraph graph; // Helpers ---------------------------------------------------------------- - /// @brief Creates a shared NodeHandle with no node (for structural tests) - std::shared_ptr> makeHandle(size_t testId = 0) { - auto h = std::make_shared>(0, nullptr); + /// @brief Creates a shared NodeHandle with no node (for structural tests) + std::shared_ptr makeHandle(size_t testId = 0) { + auto h = std::make_shared(0, nullptr); return h; } - /// @brief Creates a shared NodeHandle with a MockNode - std::shared_ptr> makeHandleWithNode(bool destructible = true) { - return std::make_shared>(0, std::make_unique(destructible)); + /// @brief Creates a shared NodeHandle with a MockNode + std::shared_ptr makeHandleWithNode(bool destructible = true) { + std::unique_ptr obj = std::make_unique(destructible); + return std::make_shared(0, std::move(obj)); } /// @brief Adds N nodes with test identifiers 0..N-1 and returns their handles - std::vector>> addNodes( - size_t n, - bool withAudioNode = false) { - std::vector>> handles; + std::vector> addNodes(size_t n, bool withAudioNode = false) { + std::vector> handles; handles.reserve(n); for (size_t i = 0; i < n; i++) { auto h = withAudioNode ? makeHandleWithNode() : makeHandle(i); @@ -293,7 +292,9 @@ TEST_F(AudioGraphTest, Compact_RemovesOnceDestructible) { EXPECT_EQ(graph.size(), 2u); // Now make it destructible - h1->audioNode->setDestructible(true); + auto *mockNode = dynamic_cast(h1->audioNode.get()); + ASSERT_NE(mockNode, nullptr); + mockNode->setDestructible(true); graph.process(); // second pass: node 1 should be removed EXPECT_EQ(graph.size(), 1u); diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/BridgeNodeTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/BridgeNodeTest.cpp new file mode 100644 index 000000000..3751ef8ea --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/BridgeNodeTest.cpp @@ -0,0 +1,619 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "MockGraphProcessor.h" +#include "TestGraphUtils.h" + +namespace audioapi::utils::graph { + +// ========================================================================= +// A. isProcessable contract +// ========================================================================= + +TEST(BridgeNodeContract, MockNodeIsProcessable) { + MockNode node; + EXPECT_TRUE(node.isProcessable()); +} + +TEST(BridgeNodeContract, BridgeNodeIsNotProcessable) { + BridgeNode bridge(nullptr); + EXPECT_FALSE(bridge.isProcessable()); +} + +TEST(BridgeNodeContract, BridgeNodeIsAlwaysDestructible) { + BridgeNode bridge(nullptr); + EXPECT_TRUE(bridge.canBeDestructed()); +} + +TEST(BridgeNodeContract, NonProcessableMockNodeIsNotProcessable) { + NonProcessableMockNode node; + EXPECT_FALSE(node.isProcessable()); + EXPECT_TRUE(node.canBeDestructed()); +} + +TEST(BridgeNodeContract, BridgeNodeStoresParam) { + // Use a dummy pointer to verify storage + auto *fakeParam = reinterpret_cast(0xDEAD); + BridgeNode bridge(fakeParam); + EXPECT_EQ(bridge.param(), fakeParam); +} + +// ========================================================================= +// B. Graph structural tests (HostGraph + AudioGraph) +// ========================================================================= + +class BridgeGraphTest : public ::testing::Test { + protected: + using HNode = HostGraph::Node; + using AGEvent = HostGraph::AGEvent; + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + + AudioGraph audioGraph; + HostGraph hostGraph; + DisposerImpl disposer_{64}; + + HNode *addMockNode() { + auto obj = std::make_unique(); + auto handle = std::make_shared(0, std::move(obj)); + auto [hostNode, event] = hostGraph.addNode(handle); + event(audioGraph, disposer_); + return hostNode; + } + + HNode *addBridgeNode(AudioParam *param = nullptr) { + auto obj = std::make_unique(param); + auto handle = std::make_shared(0, std::move(obj)); + auto [hostNode, event] = hostGraph.addNode(handle); + event(audioGraph, disposer_); + return hostNode; + } + + bool addEdge(HNode *from, HNode *to) { + auto result = hostGraph.addEdge(from, to); + if (result.is_ok()) { + auto event = std::move(result).unwrap(); + event(audioGraph, disposer_); + return true; + } + return false; + } + + bool removeEdge(HNode *from, HNode *to) { + auto result = hostGraph.removeEdge(from, to); + if (result.is_ok()) { + auto event = std::move(result).unwrap(); + event(audioGraph, disposer_); + return true; + } + return false; + } + + bool removeNode(HNode *node) { + auto result = hostGraph.removeNode(node); + if (result.is_ok()) { + auto event = std::move(result).unwrap(); + event(audioGraph, disposer_); + return true; + } + return false; + } +}; + +TEST_F(BridgeGraphTest, BridgeCreatesThreeNodePath) { + auto *source = addMockNode(); + auto *owner = addMockNode(); + auto *bridge = addBridgeNode(); + + ASSERT_TRUE(addEdge(source, bridge)); + ASSERT_TRUE(addEdge(bridge, owner)); + + // 3 nodes in graph + EXPECT_EQ(audioGraph.size(), 3u); + + // Topo sort should place them: source, bridge, owner + audioGraph.process(); + + // Verify source comes before bridge comes before owner + auto srcIdx = source->handle->index; + auto bridgeIdx = bridge->handle->index; + auto ownerIdx = owner->handle->index; + EXPECT_LT(srcIdx, bridgeIdx); + EXPECT_LT(bridgeIdx, ownerIdx); +} + +TEST_F(BridgeGraphTest, CycleDetectionThroughBridges) { + // Create: A → bridge → B → A would be a cycle + auto *nodeA = addMockNode(); + auto *nodeB = addMockNode(); + auto *bridge = addBridgeNode(); + + ASSERT_TRUE(addEdge(nodeA, bridge)); + ASSERT_TRUE(addEdge(bridge, nodeB)); + + // Now B → A should be rejected as a cycle + auto result = hostGraph.addEdge(nodeB, nodeA); + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::CYCLE_DETECTED); +} + +TEST_F(BridgeGraphTest, DuplicateEdgeRejectionWithBridges) { + auto *source = addMockNode(); + auto *bridge = addBridgeNode(); + + ASSERT_TRUE(addEdge(source, bridge)); + // Same edge again should be rejected + auto result = hostGraph.addEdge(source, bridge); + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::EDGE_ALREADY_EXISTS); +} + +// ========================================================================= +// C. AudioGraph::iter() filtering +// ========================================================================= + +class BridgeIterTest : public ::testing::Test { + protected: + using HNode = HostGraph::Node; + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + + AudioGraph audioGraph; + HostGraph hostGraph; + DisposerImpl disposer_{64}; + + HNode *addNode(std::unique_ptr obj) { + auto handle = std::make_shared(0, std::move(obj)); + auto [hostNode, event] = hostGraph.addNode(handle); + event(audioGraph, disposer_); + return hostNode; + } + + bool addEdge(HNode *from, HNode *to) { + auto result = hostGraph.addEdge(from, to); + if (result.is_ok()) { + std::move(result).unwrap()(audioGraph, disposer_); + return true; + } + return false; + } +}; + +TEST_F(BridgeIterTest, IterSkipsNonProcessableNodes) { + auto *processable1 = addNode(std::make_unique()); + auto *nonProcessable = addNode(std::make_unique()); + auto *processable2 = addNode(std::make_unique()); + + ASSERT_TRUE(addEdge(processable1, nonProcessable)); + ASSERT_TRUE(addEdge(nonProcessable, processable2)); + audioGraph.process(); + + // iter() should only yield 2 nodes (skip the non-processable one) + size_t count = 0; + for (auto &&[graphObject, inputs] : audioGraph.iter()) { + EXPECT_TRUE(graphObject.isProcessable()); + count++; + } + EXPECT_EQ(count, 2u); +} + +TEST_F(BridgeIterTest, AllProcessableNodesInTopoOrder) { + // A → bridge → B → C + auto *a = addNode(std::make_unique(nullptr, 1)); + auto *bridge = addNode(std::make_unique(nullptr)); + auto *b = addNode(std::make_unique(nullptr, 2)); + auto *c = addNode(std::make_unique(nullptr, 3)); + + ASSERT_TRUE(addEdge(a, bridge)); + ASSERT_TRUE(addEdge(bridge, b)); + ASSERT_TRUE(addEdge(b, c)); + audioGraph.process(); + + // Should yield A, B, C in topo order (bridge skipped) + std::vector values; + for (auto &&[graphObject, inputs] : audioGraph.iter()) { + auto *node = dynamic_cast(&graphObject); + ASSERT_NE(node, nullptr); + values.push_back(node->value.load()); + } + ASSERT_EQ(values.size(), 3u); + EXPECT_EQ(values[0], 1); + EXPECT_EQ(values[1], 2); + EXPECT_EQ(values[2], 3); +} + +TEST_F(BridgeIterTest, InputsViewMayReferenceBridgeIndices) { + // source → bridge → owner + // iter() skips bridge but owner's input list in AudioGraph still + // references the bridge's index. Callers use asAudioNode() to handle this. + auto *source = addNode(std::make_unique()); + auto *bridge = addNode(std::make_unique(nullptr)); + auto *owner = addNode(std::make_unique()); + + ASSERT_TRUE(addEdge(source, bridge)); + ASSERT_TRUE(addEdge(bridge, owner)); + audioGraph.process(); + + size_t processableCount = 0; + for (auto &&[graphObject, inputs] : audioGraph.iter()) { + processableCount++; + // Owner should see bridge as input (which is a BridgeNode, not AudioNode) + for (const auto &input : inputs) { + // Input could be bridge or source — both are valid GraphObjects + (void)input; + } + } + EXPECT_EQ(processableCount, 2u); // source + owner (bridge skipped) +} + +// ========================================================================= +// D. Compaction tests +// ========================================================================= + +TEST_F(BridgeGraphTest, OrphanedBridgeWithNoInputsRemoved) { + auto *bridge = addBridgeNode(); + EXPECT_EQ(audioGraph.size(), 1u); + + // Mark orphaned + removeNode(bridge); + audioGraph.process(); + + EXPECT_EQ(audioGraph.size(), 0u); +} + +TEST_F(BridgeGraphTest, SourceRemovalCascadesBridgeRemoval) { + auto *source = addMockNode(); + auto *bridge = addBridgeNode(); + auto *owner = addMockNode(); + + ASSERT_TRUE(addEdge(source, bridge)); + ASSERT_TRUE(addEdge(bridge, owner)); + audioGraph.process(); + EXPECT_EQ(audioGraph.size(), 3u); + + // Remove source — bridge loses its only input + removeNode(source); + audioGraph.process(); + + // Source compacted (orphaned, no inputs, destructible) + // Bridge compacted (orphaned via edge removal cascade — its input was removed) + // Owner stays (not orphaned) + // Actually: source is orphaned+no inputs → removed + // Then bridge has no inputs → but bridge is NOT orphaned unless explicitly marked + // Bridge keeps its edge to owner. Bridge itself is not orphaned. + // So only source is removed. + // After first process: source removed, bridge has no inputs but not orphaned. + // Bridge won't be compacted unless it's also orphaned. + // This is correct — bridge removal needs to be done via disconnectParam or removeNodeWithBridges. + EXPECT_EQ(audioGraph.size(), 2u); // bridge + owner remain +} + +TEST_F(BridgeGraphTest, BridgeOrphanedAndNoInputsGetsCompacted) { + auto *source = addMockNode(); + auto *bridge = addBridgeNode(); + auto *owner = addMockNode(); + + ASSERT_TRUE(addEdge(source, bridge)); + ASSERT_TRUE(addEdge(bridge, owner)); + audioGraph.process(); + EXPECT_EQ(audioGraph.size(), 3u); + + // Orphan source and bridge + removeNode(source); + removeEdge(bridge, owner); + removeNode(bridge); + audioGraph.process(); + + // Both source and bridge should be compacted + EXPECT_EQ(audioGraph.size(), 1u); // only owner remains +} + +// ========================================================================= +// E. Full Graph wrapper integration +// ========================================================================= + +class BridgeGraphWrapperTest : public ::testing::Test { + protected: + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + DisposerImpl disposer_{64}; + std::shared_ptr graph; + + void SetUp() override { + graph = std::make_shared(4096, &disposer_); + } + + void processAll() { + graph->processEvents(); + graph->process(); + } +}; + +TEST_F(BridgeGraphWrapperTest, ConnectParamCreatesBridge) { + auto *source = graph->addNode(std::make_unique()); + auto *owner = graph->addNode(std::make_unique()); + auto *fakeParam = reinterpret_cast(0x1234); + + auto result = graph->connectParam(source, owner, fakeParam); + ASSERT_TRUE(result.is_ok()); + + processAll(); + + // Should have 3 nodes: source, bridge, owner + size_t iterCount = 0; + for (auto &&[graphObject, inputs] : graph->iter()) { + iterCount++; + } + // iter() skips non-processable bridge, so we see 2 + EXPECT_EQ(iterCount, 2u); +} + +TEST_F(BridgeGraphWrapperTest, DisconnectParamRemovesBridge) { + auto *source = graph->addNode(std::make_unique()); + auto *owner = graph->addNode(std::make_unique()); + auto *fakeParam = reinterpret_cast(0x1234); + + ASSERT_TRUE(graph->connectParam(source, owner, fakeParam).is_ok()); + processAll(); + + ASSERT_TRUE(graph->disconnectParam(source, owner, fakeParam).is_ok()); + processAll(); + + // Bridge should be compacted away (orphaned + no inputs) + size_t iterCount = 0; + for (auto &&[graphObject, inputs] : graph->iter()) { + iterCount++; + } + EXPECT_EQ(iterCount, 2u); // source + owner +} + +TEST_F(BridgeGraphWrapperTest, DuplicateConnectParamRejected) { + auto *source = graph->addNode(std::make_unique()); + auto *owner = graph->addNode(std::make_unique()); + auto *fakeParam = reinterpret_cast(0x1234); + + ASSERT_TRUE(graph->connectParam(source, owner, fakeParam).is_ok()); + + // Same connection again should fail + auto result = graph->connectParam(source, owner, fakeParam); + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::EDGE_ALREADY_EXISTS); +} + +TEST_F(BridgeGraphWrapperTest, ConnectParamCycleDetected) { + auto *nodeA = graph->addNode(std::make_unique()); + auto *nodeB = graph->addNode(std::make_unique()); + auto *fakeParam = reinterpret_cast(0x1234); + + // A → B (regular edge) + ASSERT_TRUE(graph->addEdge(nodeA, nodeB).is_ok()); + + // Now try B →(param)→ A — this would create: B → bridge → A + // Combined with A → B, this creates cycle: A → B → bridge → A + auto result = graph->connectParam(nodeB, nodeA, fakeParam); + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::CYCLE_DETECTED); +} + +TEST_F(BridgeGraphWrapperTest, OwnerRemovalCascadesBridgeCleanup) { + auto *source = graph->addNode(std::make_unique()); + auto *owner = graph->addNode(std::make_unique()); + auto *fakeParam = reinterpret_cast(0x1234); + + ASSERT_TRUE(graph->connectParam(source, owner, fakeParam).is_ok()); + processAll(); + + // Remove owner — should cascade remove the bridge + ASSERT_TRUE(graph->removeNodeWithBridges(owner).is_ok()); + processAll(); + + // Only source should remain as processable + size_t iterCount = 0; + for (auto &&[graphObject, inputs] : graph->iter()) { + iterCount++; + } + EXPECT_EQ(iterCount, 1u); +} + +TEST_F(BridgeGraphWrapperTest, SourceRemovalCascadesBridgeCleanup) { + auto *source = graph->addNode(std::make_unique()); + auto *owner = graph->addNode(std::make_unique()); + auto *fakeParam = reinterpret_cast(0x1234); + + ASSERT_TRUE(graph->connectParam(source, owner, fakeParam).is_ok()); + processAll(); + + // Remove source — should cascade remove the bridge + ASSERT_TRUE(graph->removeNodeWithBridges(source).is_ok()); + processAll(); + + // Only owner should remain as processable + size_t iterCount = 0; + for (auto &&[graphObject, inputs] : graph->iter()) { + iterCount++; + } + EXPECT_EQ(iterCount, 1u); +} + +TEST_F(BridgeGraphWrapperTest, MultipleBridgesFromSameSource) { + auto *source = graph->addNode(std::make_unique()); + auto *ownerA = graph->addNode(std::make_unique()); + auto *ownerB = graph->addNode(std::make_unique()); + auto *paramA = reinterpret_cast(0xA); + auto *paramB = reinterpret_cast(0xB); + + ASSERT_TRUE(graph->connectParam(source, ownerA, paramA).is_ok()); + ASSERT_TRUE(graph->connectParam(source, ownerB, paramB).is_ok()); + processAll(); + + // Disconnect one + ASSERT_TRUE(graph->disconnectParam(source, ownerA, paramA).is_ok()); + processAll(); + + // Other bridge should still exist (source → bridge → ownerB) + // Disconnected bridge should be compacted away + + // Connect again should work + ASSERT_TRUE(graph->connectParam(source, ownerA, paramA).is_ok()); + processAll(); +} + +TEST_F(BridgeGraphWrapperTest, ConcurrentWithMockGraphProcessor) { + using Processor = audioapi::test::MockGraphProcessor; + // Pre-allocate node/pool capacity to avoid grow-event allocations inside + // the AudioThreadGuard scope. + auto sharedGraph = std::make_shared(4096, &disposer_, 16, 64); + Processor processor(*sharedGraph); + processor.start(); + + auto *source = sharedGraph->addNode(std::make_unique(nullptr, 10)); + auto *owner = sharedGraph->addNode(std::make_unique(nullptr, 20)); + auto *fakeParam = reinterpret_cast(0x42); + + ASSERT_TRUE(sharedGraph->connectParam(source, owner, fakeParam).is_ok()); + + // Let processor run a few cycles + while (processor.cyclesCompleted() < 10) { + std::this_thread::yield(); + } + + ASSERT_TRUE(sharedGraph->disconnectParam(source, owner, fakeParam).is_ok()); + + while (processor.cyclesCompleted() < 20) { + std::this_thread::yield(); + } + + processor.stop(); + EXPECT_TRUE(processor.allocationClean()); +} + +// ========================================================================= +// F. Fuzz test extension with connectParam/disconnectParam +// ========================================================================= + +class BridgeFuzzTest : public ::testing::TestWithParam { + protected: + using HNode = HostGraph::Node; + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + + DisposerImpl disposer_{64}; + std::shared_ptr graph; + std::mt19937_64 rng; + std::vector liveNodes; + std::vector fakeParams; + + void SetUp() override { + graph = std::make_shared(4096, &disposer_); + rng.seed(GetParam()); + + // Create a set of fake param pointers + for (int i = 1; i <= 8; i++) { + fakeParams.push_back(reinterpret_cast(static_cast(i * 0x100))); + } + } + + void processAll() { + graph->processEvents(); + graph->process(); + } + + HNode *pickRandom() { + if (liveNodes.empty()) + return nullptr; + return liveNodes[std::uniform_int_distribution(0, liveNodes.size() - 1)(rng)]; + } + + AudioParam *pickParam() { + return fakeParams[std::uniform_int_distribution(0, fakeParams.size() - 1)(rng)]; + } +}; + +TEST_P(BridgeFuzzTest, RandomParamOps) { + size_t initialCount = std::uniform_int_distribution(4, 16)(rng); + size_t opCount = std::uniform_int_distribution(50, 200)(rng); + + // Seed nodes + for (size_t i = 0; i < initialCount; i++) { + liveNodes.push_back(graph->addNode(std::make_unique())); + } + processAll(); + + for (size_t i = 0; i < opCount; i++) { + size_t op = std::uniform_int_distribution(0, 99)(rng); + + if (op < 10) { + // Add node + liveNodes.push_back(graph->addNode(std::make_unique())); + + } else if (op < 25) { + // Add regular edge + auto *a = pickRandom(); + auto *b = pickRandom(); + if (a && b && a != b) { + (void)graph->addEdge(a, b); + } + + } else if (op < 40) { + // Connect param + auto *source = pickRandom(); + auto *owner = pickRandom(); + if (source && owner && source != owner) { + (void)graph->connectParam(source, owner, pickParam()); + } + + } else if (op < 55) { + // Disconnect param + auto *source = pickRandom(); + auto *owner = pickRandom(); + if (source && owner) { + (void)graph->disconnectParam(source, owner, pickParam()); + } + + } else if (op < 70) { + // Remove node with bridges + auto *n = pickRandom(); + if (n) { + (void)graph->removeNodeWithBridges(n); + liveNodes.erase(std::remove(liveNodes.begin(), liveNodes.end(), n), liveNodes.end()); + } + + } else if (op < 85) { + // Remove regular edge + auto *a = pickRandom(); + auto *b = pickRandom(); + if (a && b) { + (void)graph->removeEdge(a, b); + } + + } else { + // Process + processAll(); + } + } + + // Final process — should not crash or produce bad state + processAll(); + + // Verify iter doesn't crash + for (auto &&[graphObject, inputs] : graph->iter()) { + (void)graphObject; + for (const auto &input : inputs) { + (void)input; + } + } +} + +INSTANTIATE_TEST_SUITE_P( + Seeds, + BridgeFuzzTest, + ::testing::Range(uint64_t{0}, uint64_t{100}), + [](const ::testing::TestParamInfo &info) { + return "seed_" + std::to_string(info.param); + }); + +} // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp index 6c26d867d..5b4d18b53 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp @@ -18,14 +18,14 @@ namespace audioapi::utils::graph { class GraphCycleDebugTest : public ::testing::TestWithParam { protected: using MNode = MockNode; - using HNode = HostGraph::Node; - using AGEvent = HostGraph::AGEvent; + using HNode = HostGraph::Node; + using AGEvent = HostGraph::AGEvent; - static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; std::mt19937_64 rng; - AudioGraph audioGraph; - HostGraph hostGraph; + AudioGraph audioGraph; + HostGraph hostGraph; DisposerImpl disposer_{64}; std::vector liveNodes; size_t nextId = 0; @@ -37,7 +37,8 @@ class GraphCycleDebugTest : public ::testing::TestWithParam { // ── Helpers ───────────────────────────────────────────────────────────── HNode *doAddNode() { - auto handle = std::make_shared>(0, std::make_unique()); + std::unique_ptr obj = std::make_unique(); + auto handle = std::make_shared(0, std::move(obj)); auto [hostNode, event] = hostGraph.addNode(handle); size_t id = nextId++; hostNode->test_node_identifier__ = id; diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphFuzzTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphFuzzTest.cpp index 9e5829f3e..3ce789910 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphFuzzTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphFuzzTest.cpp @@ -15,6 +15,7 @@ using namespace audioapi::utils::graph; using audioapi::test::MockGraphProcessor; +using audioapi::utils::DisposerImpl; // ========================================================================= // Fixture — parameterized by seed for reproducible randomized testing @@ -23,12 +24,14 @@ using audioapi::test::MockGraphProcessor; class GraphFuzzTest : public ::testing::TestWithParam { protected: using PNode = ProcessableMockNode; - using HNode = HostGraph::Node; - using Res = Graph::Res; - using ResultError = Graph::ResultError; + using HNode = HostGraph::Node; + using Res = Graph::Res; + using ResultError = Graph::ResultError; + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + DisposerImpl disposer_{64}; std::mt19937_64 rng; - std::unique_ptr> graph; + std::unique_ptr graph; std::vector nodes; // tracks live (non-removed) nodes size_t initialNodeCount; size_t operationCount; @@ -49,7 +52,7 @@ class GraphFuzzTest : public ::testing::TestWithParam { // Ensure graph growth does not happen on the audio thread during this fuzz run. const auto maxNodes = static_cast(initialNodeCount + operationCount + 64); const auto maxEdges = static_cast(operationCount * 2 + 64); - graph = std::make_unique>(4096, maxNodes, maxEdges); + graph = std::make_unique(4096, &disposer_, maxNodes, maxEdges); // Randomly partition the range 0..99 into 4 operation weights size_t total = 100; diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp index 944dd055f..db8943cbe 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp @@ -13,17 +13,19 @@ namespace audioapi::utils::graph { class GraphTest : public ::testing::Test { protected: - std::unique_ptr> graph; + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + DisposerImpl disposer_{64}; + std::unique_ptr graph; void SetUp() override { - graph = std::make_unique>(4096); + graph = std::make_unique(4096, &disposer_); } - const AudioGraph &getAudioGraph() { + const AudioGraph &getAudioGraph() { return graph->audioGraph; } - const HostGraph &getHostGraph() { + const HostGraph &getHostGraph() { return graph->hostGraph; } }; @@ -32,7 +34,7 @@ TEST_F(GraphTest, EventsAreScheduledButNotExecutedUntilProcess) { auto *node = graph->addNode(); ASSERT_NE(node, nullptr); - // AudioGraph should not be aware of the node yet (event not processed) + // AudioGraph should not be aware of the node yet (event not processed) const auto &ag = getAudioGraph(); size_t sizeBefore = ag.size(); @@ -55,8 +57,7 @@ TEST_F(GraphTest, NoUselessEventsScheduled) { const auto &ag = getAudioGraph(); // Convert to verify auto initialAdj = TestGraphUtils::convertAudioGraphToAdjacencyList( - const_cast &>( - ag)); // casting const away if utils need it, or verifyutils usage + const_cast(ag)); // casting const away if utils need it, or verifyutils usage // Try adding duplicate edge (should fail and NOT schedule event) ASSERT_TRUE(graph->addEdge(node1, node2).is_ok()); // Success first time @@ -64,19 +65,18 @@ TEST_F(GraphTest, NoUselessEventsScheduled) { // Result of valid op auto intermediateAdj = - TestGraphUtils::convertAudioGraphToAdjacencyList(const_cast &>(ag)); + TestGraphUtils::convertAudioGraphToAdjacencyList(const_cast(ag)); // Try adding SAME edge (should fail) auto result = graph->addEdge(node1, node2); EXPECT_TRUE(result.is_err()); - EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::EDGE_ALREADY_EXISTS); + EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::EDGE_ALREADY_EXISTS); // Even if we call processEvents, state should not change (and no event should be consumed ideally, // impossible to check queue count easily without friend or mock, but state check is good enough) graph->processEvents(); - auto finalAdj = - TestGraphUtils::convertAudioGraphToAdjacencyList(const_cast &>(ag)); + auto finalAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(const_cast(ag)); EXPECT_EQ(intermediateAdj, finalAdj); } @@ -86,7 +86,7 @@ TEST_F(GraphTest, ThreadRaceConcurrency) { // One thread processes events (consumer) std::atomic running{true}; - std::vector::Node *> nodes; + std::vector nodes; // Add initial nodes for (int i = 0; i < 10; ++i) { @@ -112,7 +112,7 @@ TEST_F(GraphTest, ThreadRaceConcurrency) { nodes.push_back(n); } else if (op == 1 && nodes.size() > 2) { // Add edge - HostGraph::Node *n1, *n2; + HostGraph::Node *n1, *n2; { n1 = nodes[rand_r(&seed) % nodes.size()]; n2 = nodes[rand_r(&seed) % nodes.size()]; @@ -123,7 +123,7 @@ TEST_F(GraphTest, ThreadRaceConcurrency) { } } else if (op == 2 && nodes.size() > 5) { // Remove edge - HostGraph::Node *n1, *n2; + HostGraph::Node *n1, *n2; { n1 = nodes[rand_r(&seed) % nodes.size()]; n2 = nodes[rand_r(&seed) % nodes.size()]; @@ -146,10 +146,8 @@ TEST_F(GraphTest, ThreadRaceConcurrency) { const auto &ag = getAudioGraph(); const auto &hg = getHostGraph(); - auto audioAdj = - TestGraphUtils::convertAudioGraphToAdjacencyList(const_cast &>(ag)); - auto hostAdj = - TestGraphUtils::convertHostGraphToAdjacencyList(const_cast &>(hg)); + auto audioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(const_cast(ag)); + auto hostAdj = TestGraphUtils::convertHostGraphToAdjacencyList(const_cast(hg)); // They should match EXPECT_EQ(audioAdj, hostAdj); diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/HostGraphTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/HostGraphTest.cpp index e796c0003..e2554b355 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/HostGraphTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/HostGraphTest.cpp @@ -1,5 +1,5 @@ +#include #include -#include #include #include #include @@ -13,19 +13,19 @@ namespace audioapi::utils::graph { class HostGraphTest : public ::testing::Test { protected: - static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; + static constexpr size_t kPayloadSize = HostGraph::kDisposerPayloadSize; DisposerImpl disposer_{64}; void verifyAddEdge( - HostGraph &hostGraph, - AudioGraph &audioGraph, + HostGraph &hostGraph, + AudioGraph &audioGraph, size_t fromId, size_t toId, const std::vector> &expectedAdjacencyList) { // Find nodes by ID - HostGraph::Node *fromNode = nullptr; - HostGraph::Node *toNode = nullptr; + HostGraph::Node *fromNode = nullptr; + HostGraph::Node *toNode = nullptr; for (auto *n : hostGraph.nodes) { if (n->test_node_identifier__ == fromId) @@ -44,26 +44,25 @@ class HostGraphTest : public ::testing::Test { auto result = hostGraph.addEdge(fromNode, toNode); ASSERT_TRUE(result.is_ok()) << "addEdge failed"; - // Verify AudioGraph UNCHANGED + // Verify AudioGraph UNCHANGED auto intermediateAudioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); - EXPECT_EQ(initialAudioAdj, intermediateAudioAdj) - << "AudioGraph changed before event execution"; + EXPECT_EQ(initialAudioAdj, intermediateAudioAdj) << "AudioGraph changed before event execution"; // Perform Event auto event = std::move(result).unwrap(); event(audioGraph, disposer_); - // Verify AudioGraph UPDATED and CONSISTENT + // Verify AudioGraph UPDATED and CONSISTENT auto finalAudioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); auto finalHostAdj = TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph); EXPECT_EQ(finalAudioAdj, expectedAdjacencyList) - << "AudioGraph does not match expected adjacency list"; + << "AudioGraph does not match expected adjacency list"; EXPECT_EQ(finalHostAdj, expectedAdjacencyList) - << "HostGraph does not match expected adjacency list"; + << "HostGraph does not match expected adjacency list"; } - HostGraph::Node *findNode(const HostGraph &hostGraph, size_t id) { + HostGraph::Node *findNode(const HostGraph &hostGraph, size_t id) { for (auto *n : hostGraph.nodes) { if (n->test_node_identifier__ == id) return n; @@ -79,19 +78,19 @@ TEST_F(HostGraphTest, AddNode) { {} // 2 }); - // Create a new handle and add it via HostGraph - auto handle = std::make_shared>(0, nullptr); + // Create a new handle and add it via HostGraph + auto handle = std::make_shared(0, nullptr); auto [hostNode, event] = hostGraph.addNode(handle); EXPECT_EQ(hostNode->handle, handle); hostNode->test_node_identifier__ = 3; - // AudioGraph unchanged before event + // AudioGraph unchanged before event EXPECT_EQ(audioGraph.size(), 3u); event(audioGraph, disposer_); - // After event: node added to AudioGraph + // After event: node added to AudioGraph EXPECT_EQ(audioGraph.size(), 4u); audioGraph[handle->index].test_node_identifier__ = 3; @@ -200,21 +199,21 @@ TEST_F(HostGraphTest, AddEdge_CycleDetection) { auto hostAdjBefore = TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph); auto audioAdjBefore = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); - HostGraph::Node *node0 = findNode(hostGraph, 0); - HostGraph::Node *node2 = findNode(hostGraph, 2); + HostGraph::Node *node0 = findNode(hostGraph, 0); + HostGraph::Node *node2 = findNode(hostGraph, 2); // Try adding cycle 2->0 auto result = hostGraph.addEdge(node2, node0); EXPECT_TRUE(result.is_err()); - EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::CYCLE_DETECTED); + EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::CYCLE_DETECTED); - // HostGraph should NOT change + // HostGraph should NOT change auto hostAdjAfter = TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph); - EXPECT_EQ(hostAdjBefore, hostAdjAfter) << "HostGraph modified despite cycle detection"; + EXPECT_EQ(hostAdjBefore, hostAdjAfter) << "HostGraph modified despite cycle detection"; - // AudioGraph should NOT change (no event executed) + // AudioGraph should NOT change (no event executed) auto audioAdjAfter = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); - EXPECT_EQ(audioAdjBefore, audioAdjAfter) << "AudioGraph modified"; + EXPECT_EQ(audioAdjBefore, audioAdjAfter) << "AudioGraph modified"; } TEST_F(HostGraphTest, AddEdge_LargeSpecificGraph) { @@ -267,8 +266,8 @@ TEST_F(HostGraphTest, AddEdge_GridInterconnect) { // If we try 5->0 -> Cycle (5 reachable from 0) auto hostAdjBefore = TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph); - HostGraph::Node *node5 = findNode(hostGraph, 5); - HostGraph::Node *node0 = findNode(hostGraph, 0); + HostGraph::Node *node5 = findNode(hostGraph, 5); + HostGraph::Node *node0 = findNode(hostGraph, 0); auto result = hostGraph.addEdge(node5, node0); EXPECT_TRUE(result.is_err()); @@ -276,17 +275,17 @@ TEST_F(HostGraphTest, AddEdge_GridInterconnect) { } // --------------------------------------------------------------------------- -// BUG demonstration: ghost node in AudioGraph causes accepted cycle +// BUG demonstration: ghost node in AudioGraph causes accepted cycle // --------------------------------------------------------------------------- // -// When a node is removed from HostGraph it is deleted immediately (edges torn -// down, pointer freed). The corresponding AudioGraph event only marks the +// When a node is removed from HostGraph it is deleted immediately (edges torn +// down, pointer freed). The corresponding AudioGraph event only marks the // node as `orphaned` — it stays in the vector with all its edges until // compaction eventually removes it. // -// This creates a window where HostGraph no longer "sees" the node, so its +// This creates a window where HostGraph no longer "sees" the node, so its // cycle-detection (hasPath) can miss paths that still exist in AudioGraph. -// If a new edge is added through that blind-spot, AudioGraph ends up with a +// If a new edge is added through that blind-spot, AudioGraph ends up with a // cycle and toposort produces garbage. // TEST_F(HostGraphTest, RemoveNode_GhostNodeMustNotAllowCycle) { @@ -297,32 +296,31 @@ TEST_F(HostGraphTest, RemoveNode_GhostNodeMustNotAllowCycle) { {} // 2 }); - HostGraph::Node *node0 = findNode(hostGraph, 0); - HostGraph::Node *node1 = findNode(hostGraph, 1); - HostGraph::Node *node2 = findNode(hostGraph, 2); + HostGraph::Node *node0 = findNode(hostGraph, 0); + HostGraph::Node *node1 = findNode(hostGraph, 1); + HostGraph::Node *node2 = findNode(hostGraph, 2); ASSERT_NE(node0, nullptr); ASSERT_NE(node1, nullptr); ASSERT_NE(node2, nullptr); - // ── Step 1: remove node 1 from HostGraph ── + // ── Step 1: remove node 1 from HostGraph ── auto removeResult = hostGraph.removeNode(node1); ASSERT_TRUE(removeResult.is_ok()); - // Execute the remove-event on AudioGraph (only sets orphaned=true). + // Execute the remove-event on AudioGraph (only sets orphaned=true). auto removeEvent = std::move(removeResult).unwrap(); removeEvent(audioGraph, disposer_); - // AudioGraph still has the ghost: 0 → 1(orphaned) → 2 + // AudioGraph still has the ghost: 0 → 1(orphaned) → 2 EXPECT_EQ(audioGraph.size(), 3u); // ── Step 2: add edge 2 → 0 ── - // Because node 1 still bridges 0→2 in AudioGraph, this would create - // a cycle: 0 → 1 → 2 → 0. HostGraph MUST reject it. + // Because node 1 still bridges 0→2 in AudioGraph, this would create + // a cycle: 0 → 1 → 2 → 0. HostGraph MUST reject it. auto addResult = hostGraph.addEdge(node2, node0); - EXPECT_TRUE(addResult.is_err()) - << "HostGraph should detect the cycle through the ghost node"; - EXPECT_EQ(addResult.unwrap_err(), HostGraph::ResultError::CYCLE_DETECTED); + EXPECT_TRUE(addResult.is_err()) << "HostGraph should detect the cycle through the ghost node"; + EXPECT_EQ(addResult.unwrap_err(), HostGraph::ResultError::CYCLE_DETECTED); } } // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/MockGraphProcessor.h b/packages/react-native-audio-api/common/cpp/test/src/graph/MockGraphProcessor.h index 2d296d7f7..aeb671b67 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/MockGraphProcessor.h +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/MockGraphProcessor.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #ifdef __APPLE__ @@ -21,15 +22,15 @@ namespace audioapi::test { /// Spawns a dedicated thread that repeatedly: /// 1. Processes SPSC events (graph mutations from the main thread) /// 2. Runs toposort + compaction -/// 3. Iterates nodes in topological order, calling `process()` if the -/// NodeType supports it (SFINAE via `requires`) +/// 3. Iterates graph objects in topological order, downcasts to `NodeType`, +/// then calls `process(inputs)` if available /// /// The thread is instrumented with AudioThreadGuard to detect heap /// allocations / deallocations and sample context-switch counters. /// /// ## Usage /// ```cpp -/// Graph graph(4096); +/// Graph graph(4096); /// MockGraphProcessor processor(graph); /// processor.start(); /// // … mutate graph from main thread … @@ -37,11 +38,11 @@ namespace audioapi::test { /// EXPECT_TRUE(processor.allocationClean()); /// ``` /// -/// @tparam NodeType the audio graph node type (must satisfy AudioGraphNode) -template +/// @tparam NodeType concrete GraphObject subtype expected by this processor. +template class MockGraphProcessor { public: - explicit MockGraphProcessor(audioapi::utils::graph::Graph &graph) : graph_(graph) {} + explicit MockGraphProcessor(audioapi::utils::graph::Graph &graph) : graph_(graph) {} ~MockGraphProcessor() { stop(); @@ -136,16 +137,21 @@ class MockGraphProcessor { cycles_.fetch_add(1, std::memory_order_relaxed); } - /// @brief Iterates nodes in topological order and calls process(inputs). + /// @brief Iterates graph objects in topological order and calls process(inputs). void processNodes() { - for (auto &&[node, inputs] : graph_.iter()) { - if constexpr (requires { node.process(inputs); }) { - node.process(inputs); + for (auto &&[graphObject, inputs] : graph_.iter()) { + auto *node = dynamic_cast(&graphObject); + if (!node) { + continue; + } + + if constexpr (requires { node->process(inputs); }) { + node->process(inputs); } } } - audioapi::utils::graph::Graph &graph_; + audioapi::utils::graph::Graph &graph_; std::thread thread_; std::atomic running_{false}; std::atomic allocationViolations_{0}; diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.cpp index c092fef18..47b5afc1f 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.cpp @@ -8,15 +8,15 @@ namespace audioapi::utils::graph { -std::pair, HostGraph> TestGraphUtils::createTestGraph( +std::pair TestGraphUtils::createTestGraph( std::vector> adjacencyList) { - HostGraph hostGraph = makeFromAdjacencyList(adjacencyList); - AudioGraph audioGraph = createAudioGraphFromHostGraph(hostGraph); + HostGraph hostGraph = makeFromAdjacencyList(adjacencyList); + AudioGraph audioGraph = createAudioGraphFromHostGraph(hostGraph); return {std::move(audioGraph), std::move(hostGraph)}; } std::vector> TestGraphUtils::convertAudioGraphToAdjacencyList( - const AudioGraph &audioGraph) { + const AudioGraph &audioGraph) { std::vector> adjacencyList; if (audioGraph.size() == 0) return {}; @@ -50,7 +50,7 @@ std::vector> TestGraphUtils::convertAudioGraphToAdjacencyLis } std::vector> TestGraphUtils::convertHostGraphToAdjacencyList( - const HostGraph &hostGraph) { + const HostGraph &hostGraph) { std::vector> adjacencyList; if (hostGraph.nodes.empty()) return {}; @@ -66,7 +66,7 @@ std::vector> TestGraphUtils::convertHostGraphToAdjacencyList for (auto *n : hostGraph.nodes) { size_t nodeId = n->test_node_identifier__; - for (HostGraph::Node *output : n->outputs) { + for (HostGraph::Node *output : n->outputs) { if (output) { adjacencyList[nodeId].push_back(output->test_node_identifier__); } @@ -77,15 +77,15 @@ std::vector> TestGraphUtils::convertHostGraphToAdjacencyList return adjacencyList; } -HostGraph TestGraphUtils::makeFromAdjacencyList( +HostGraph TestGraphUtils::makeFromAdjacencyList( const std::vector> &adjacencyList) { - HostGraph graph; - std::vector::Node *> nodesVec; + HostGraph graph; + std::vector nodesVec; nodesVec.reserve(adjacencyList.size()); for (size_t i = 0; i < adjacencyList.size(); ++i) { - auto handle = std::make_shared>(static_cast(i), nullptr); - auto *node = new HostGraph::Node(); + auto handle = std::make_shared(static_cast(i), nullptr); + auto *node = new HostGraph::Node(); node->handle = handle; node->test_node_identifier__ = i; nodesVec.push_back(node); @@ -95,8 +95,8 @@ HostGraph TestGraphUtils::makeFromAdjacencyList( for (size_t fromIndex = 0; fromIndex < adjacencyList.size(); ++fromIndex) { for (size_t toIndex : adjacencyList[fromIndex]) { if (fromIndex < nodesVec.size() && toIndex < nodesVec.size()) { - HostGraph::Node *fromNode = nodesVec[fromIndex]; - HostGraph::Node *toNode = nodesVec[toIndex]; + HostGraph::Node *fromNode = nodesVec[fromIndex]; + HostGraph::Node *toNode = nodesVec[toIndex]; fromNode->outputs.push_back(toNode); toNode->inputs.push_back(fromNode); } @@ -107,9 +107,8 @@ HostGraph TestGraphUtils::makeFromAdjacencyList( return graph; } -AudioGraph TestGraphUtils::createAudioGraphFromHostGraph( - const HostGraph &hostGraph) { - AudioGraph audioGraph; +AudioGraph TestGraphUtils::createAudioGraphFromHostGraph(const HostGraph &hostGraph) { + AudioGraph audioGraph; if (hostGraph.nodes.empty()) return audioGraph; @@ -122,7 +121,7 @@ AudioGraph TestGraphUtils::createAudioGraphFromHostGraph( audioGraph[idx].test_node_identifier__ = n->test_node_identifier__; audioGraph.pool().freeAll(audioGraph[idx].input_head); - for (HostGraph::Node *input : n->inputs) { + for (HostGraph::Node *input : n->inputs) { audioGraph.pool().push(audioGraph[idx].input_head, input->handle->index); } } diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.h b/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.h index d7fa631a2..ded44520d 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.h +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.h @@ -5,8 +5,10 @@ #define RN_AUDIO_API_TEST true // for intellisense #endif +#include #include #include +#include #include #include #include @@ -57,12 +59,26 @@ struct MockNode : AudioNode { std::atomic destructible_; }; +// ── NonProcessableMockNode ──────────────────────────────────────────────── +// Pure GraphObject subclass that is not processable. Used to test +// iter() filtering at the AudioGraph level without depending on BridgeNode. + +struct NonProcessableMockNode : GraphObject { + [[nodiscard]] bool isProcessable() const override { + return false; + } + + [[nodiscard]] bool canBeDestructed() const override { + return true; + } +}; + // ── MockHostNode ────────────────────────────────────────────────────────── -// RAII wrapper around HostNode for testing the HostNode lifecycle. +// RAII wrapper around HostNode for testing the HostNode lifecycle. -class MockHostNode : public HostNode { +class MockHostNode : public HostNode { public: - explicit MockHostNode(std::shared_ptr> graph, bool destructible = true) + explicit MockHostNode(std::shared_ptr graph, bool destructible = true) : HostNode(std::move(graph), std::make_unique(destructible)) {} }; @@ -89,8 +105,10 @@ struct ProcessableMockNode : MockNode { bool destructible = true) : MockNode(destructible), value(initialValue), processFn_(std::move(processFn)) {} - /// @brief Called by the audio thread with the inputs range from iter(). - /// Collects input values into a stack buffer — no heap allocation. + /// @brief Called by the audio thread with an input range from `Graph::iter()`. + /// + /// Supports both strongly-typed test ranges and GraphObject-based ranges, + /// collecting values into a stack buffer with no heap allocation. template void process(R &&inputs) { if (!processFn_) @@ -98,8 +116,18 @@ struct ProcessableMockNode : MockNode { int buf[kMaxInputs]; size_t n = 0; for (const auto &input : inputs) { - if (n < kMaxInputs) + if (n >= kMaxInputs) { + continue; + } + + if constexpr (requires { input.value.load(std::memory_order_acquire); }) { buf[n++] = input.value.load(std::memory_order_acquire); + } else { + auto *typed = dynamic_cast(&input); + if (typed) { + buf[n++] = typed->value.load(std::memory_order_acquire); + } + } } value.store(processFn_({buf, n}), std::memory_order_release); } @@ -115,22 +143,21 @@ class TestGraphUtils { /// @brief Creates a paired AudioGraph + HostGraph from an adjacency list. /// @param adjacencyList adjacencyList[i] = {j, k} means edges i→j, i→k /// @return (AudioGraph, HostGraph) pair with consistent structure - static std::pair, HostGraph> createTestGraph( + static std::pair createTestGraph( std::vector> adjacencyList); /// @brief Converts AudioGraph to adjacency list for equality comparison. static std::vector> convertAudioGraphToAdjacencyList( - const AudioGraph &audioGraph); + const AudioGraph &audioGraph); /// @brief Converts HostGraph to adjacency list for equality comparison. static std::vector> convertHostGraphToAdjacencyList( - const HostGraph &hostGraph); + const HostGraph &hostGraph); private: - static HostGraph makeFromAdjacencyList( - const std::vector> &adjacencyList); + static HostGraph makeFromAdjacencyList(const std::vector> &adjacencyList); - static AudioGraph createAudioGraphFromHostGraph(const HostGraph &hostGraph); + static AudioGraph createAudioGraphFromHostGraph(const HostGraph &hostGraph); }; } // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/scripts/cpplint.sh b/packages/react-native-audio-api/scripts/cpplint.sh index 306b75c7a..bc8881917 100755 --- a/packages/react-native-audio-api/scripts/cpplint.sh +++ b/packages/react-native-audio-api/scripts/cpplint.sh @@ -1,7 +1,7 @@ #!/bin/bash if which cpplint >/dev/null; then - find common/cpp android/src/main/cpp -path 'common/cpp/audioapi/libs' -prune -o -path 'common/cpp/audioapi/external' -prune -o -path 'common/cpp/audioapi/dsp/r8brain' -prune -o \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' \) -print | xargs cpplint --linelength=100 --filter=-legal/copyright,-readability/todo,-build/namespaces,-build/include_order,-whitespace,-build/c++17,-build/c++20,-runtime/references,-runtime/string,-readability/braces --quiet --recursive "$@" + find common/cpp android/src/main/cpp -path 'common/cpp/audioapi/libs' -prune -o -path 'common/cpp/audioapi/external' -prune -o -path 'common/cpp/audioapi/dsp/r8brain' -prune -o -path 'common/cpp/test/build' -prune -o \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' \) -print | xargs cpplint --linelength=100 --filter=-legal/copyright,-readability/todo,-build/namespaces,-build/include_order,-whitespace,-build/c++17,-build/c++20,-runtime/references,-runtime/string,-readability/braces --quiet --recursive "$@" else echo "error: cpplint not installed, download from https://github.com/cpplint/cpplint" 1>&2 exit 1