From 1f8331f846f751543fc12e17c5ac09cb407c6f90 Mon Sep 17 00:00:00 2001 From: poneciak Date: Thu, 5 Feb 2026 17:48:41 +0100 Subject: [PATCH 01/11] feat: prepared all files --- .../audioapi/core/utils/graph/AudioGraph.cpp | 1 + .../audioapi/core/utils/graph/AudioGraph.h | 7 ++++++ .../audioapi/core/utils/graph/HostGraph.cpp | 1 + .../cpp/audioapi/core/utils/graph/HostGraph.h | 7 ++++++ .../cpp/audioapi/core/utils/graph/README.md | 0 .../common/cpp/test/src/graph/SandboxTest.cpp | 24 +++++++++++++++++++ 6 files changed, 40 insertions(+) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/README.md create mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp new file mode 100644 index 000000000..48985b255 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp @@ -0,0 +1 @@ +#include diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h new file mode 100644 index 000000000..e4b35854c --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h @@ -0,0 +1,7 @@ +#pragma once + +namespace audioapi::utils::graph { + +class AudioGraph {}; + +} // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp new file mode 100644 index 000000000..a2319a26f --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp @@ -0,0 +1 @@ +#include diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h new file mode 100644 index 000000000..bc596d59f --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -0,0 +1,7 @@ +#pragma once + +namespace audioapi::utils::graph { + +class HostGraph {}; + +} // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/README.md b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp new file mode 100644 index 000000000..e7f163729 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp @@ -0,0 +1,24 @@ + + + +#include + +using namespace audioapi; + +class SandboxTest : public ::testing::Test { + protected: + void SetUp() override { + // Code here will be called immediately after the constructor (right + // before each test). + } + + void TearDown() override { + // Code here will be called immediately after each test (right + // before the destructor). + } +}; + + +TEST_F(SandboxTest, SampleTest) { + ASSERT_TRUE(true); +} From 96965f76d7e3c72b552ee1f2d6d73be026753123 Mon Sep 17 00:00:00 2001 From: poneciak Date: Fri, 6 Feb 2026 13:32:32 +0100 Subject: [PATCH 02/11] feat: audio graph impl --- .../audioapi/core/utils/graph/AudioGraph.cpp | 66 ++++++ .../audioapi/core/utils/graph/AudioGraph.h | 55 ++++- .../cpp/test/src/graph/AudioGraphTest.cpp | 188 ++++++++++++++++++ .../common/cpp/test/src/graph/SandboxTest.cpp | 4 +- 4 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp index 48985b255..fb9fb97bd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp @@ -1 +1,67 @@ #include +#include + +namespace audioapi::utils::graph { + +void AudioGraph::swapNodesInTopologicalOrder(Node* nodeA, Node* nodeB) { + if (nodeA == nodeB) return; + + bool adjacent = (nodeA->next == nodeB) || (nodeB->next == nodeA); + + if (adjacent) { + Node* first = (nodeA->next == nodeB) ? nodeA : nodeB; + Node* second = (first == nodeA) ? nodeB : nodeA; + + Node* prevFirst = first->prev; + Node* nextSecond = second->next; + + if (prevFirst) prevFirst->next = second; + if (nextSecond) nextSecond->prev = first; + + second->prev = prevFirst; + second->next = first; + first->prev = second; + first->next = nextSecond; + + if (head == first) head = second; + } else { + Node* prevA = nodeA->prev; + Node* nextA = nodeA->next; + Node* prevB = nodeB->prev; + Node* nextB = nodeB->next; + + if (prevA) prevA->next = nodeB; + if (nextA) nextA->prev = nodeB; + + if (prevB) prevB->next = nodeA; + if (nextB) nextB->prev = nodeA; + + nodeA->prev = prevB; + nodeA->next = nextB; + nodeB->prev = prevA; + nodeB->next = nextA; + + if (head == nodeA) head = nodeB; + else if (head == nodeB) head = nodeA; + } + + std::swap(nodeA->topologicalIndex, nodeB->topologicalIndex); +} + +AudioGraph::~AudioGraph() { + Node* current = head; + while (current) { + Node* nextNode = current->next; + delete current; + current = nextNode; + } +} + +AudioGraph::Node* AudioGraph::Iterator::next() { + if (!current) return nullptr; + Node* result = current; + current = current->next; + return result; +} + +} // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h index e4b35854c..fd9cba3ef 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h @@ -1,7 +1,60 @@ #pragma once +#include +#include +#include +#if RN_AUDIO_API_TEST +#include +#endif // RN_AUDIO_API_TEST namespace audioapi::utils::graph { -class AudioGraph {}; +// Forward declarations +#if RN_AUDIO_API_TEST +class AudioGraphTest; +#endif // RN_AUDIO_API_TEST +class HostGraph; + +class AudioGraph { + public: + struct Node { + // std::unique_ptr audioNode; + std::vector inputs; + + Node *next = nullptr; // next in topological order + Node *prev = nullptr; // previous in topological order + size_t topologicalIndex = 0; // for swapping + +#if RN_AUDIO_API_TEST + // Identifier for testing purposes only + size_t test_node_identifier__ = 0; +#endif // RN_AUDIO_API_TEST + }; + + struct Iterator { + explicit Iterator(Node *start) : current(start) {} + ~Iterator() = default; + Node *next(); + + private: + Node *current = nullptr; + }; + + AudioGraph() = default; + + /// @brief Destructor that cleans up all nodes in the graph + /// @note Graph owns all of its nodes so they are deleted here + ~AudioGraph(); + + private: + Node *head = nullptr; + + void swapNodesInTopologicalOrder(Node *nodeA, Node *nodeB); + +// Granting access +#if RN_AUDIO_API_TEST + friend class AudioGraphTest; +#endif // RN_AUDIO_API_TEST + friend class HostGraph; +}; } // namespace audioapi::utils::graph 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 new file mode 100644 index 000000000..0e5cdad43 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp @@ -0,0 +1,188 @@ +#include +#include +#include +#include +#include +#include + +namespace audioapi::utils::graph { + +class AudioGraphTest : public ::testing::Test { + protected: + void SetUp() override { + // Code here will be called immediately after the constructor (right + // before each test). + } + + void TearDown() override { + // Code here will be called immediately after each test (right + // before the destructor). + } + + // Helper to create a graph with given identifiers + std::pair> createGraphNodes(std::vector ids) { + if (ids.empty()) return {nullptr, {}}; + + AudioGraph::Node* head = new AudioGraph::Node(); + std::unordered_map nodeMap; + nodeMap[ids[0]] = head; + head->test_node_identifier__ = ids[0]; + AudioGraph::Node* tail = head; + + for (size_t i = 1; i < ids.size(); ++i) { + AudioGraph::Node* newNode = new AudioGraph::Node(); + newNode->test_node_identifier__ = ids[i]; + nodeMap[ids[i]] = newNode; + tail->next = newNode; + newNode->prev = tail; + tail = newNode; + } + + return {head, nodeMap}; + } + + void setGraphHead(AudioGraph& graph, AudioGraph::Node* head) { + graph.head = head; + } + + void swapNodes(AudioGraph& graph, AudioGraph::Node* a, AudioGraph::Node* b) { + graph.swapNodesInTopologicalOrder(a, b); + } + + AudioGraph::Node* getGraphHead(AudioGraph& graph) { + return graph.head; + } + + void verifyGraphOrder(AudioGraph& graph, std::vector expectedIds) { + AudioGraph::Node* current = getGraphHead(graph); + AudioGraph::Node* prev = nullptr; + + for (size_t i = 0; i < expectedIds.size(); ++i) { + size_t id = expectedIds[i]; + ASSERT_NE(current, nullptr) << "Expected node with id " << id << " but reached end of graph at index " << i; + EXPECT_EQ(current->test_node_identifier__, id) << "Mismatch at index " << i; + EXPECT_EQ(current->prev, prev) << "Prev pointer broken at node " << id; + + prev = current; + current = current->next; + } + EXPECT_EQ(current, nullptr) << "Graph has more nodes than expected"; + } +}; + + +TEST_F(AudioGraphTest, swapAdjacent_A_B_Middle) { + // A -> B + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 2 and 3 + swapNodes(graph, nodeMap[2], nodeMap[3]); + + verifyGraphOrder(graph, {1, 3, 2, 4}); +} + +TEST_F(AudioGraphTest, swapAdjacent_B_A_Middle) { + // B -> A (Swapping 2 and 3 by passing (3, 2) instead of (2,3)) + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 3 and 2 (where 2 is before 3 in graph) + // Calling swap with (3, 2) should behave same as (2, 3) effectively swapping their positions + swapNodes(graph, nodeMap[3], nodeMap[2]); + + verifyGraphOrder(graph, {1, 3, 2, 4}); +} + +TEST_F(AudioGraphTest, swapAdjacent_Head_Next) { + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 1 and 2 (Head and Next) + swapNodes(graph, nodeMap[1], nodeMap[2]); + + verifyGraphOrder(graph, {2, 1, 3}); +} + +TEST_F(AudioGraphTest, swapAdjacent_TailPrev_Tail) { + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 2 and 3 (TailPrev and Tail) + swapNodes(graph, nodeMap[2], nodeMap[3]); + + verifyGraphOrder(graph, {1, 3, 2}); +} + +TEST_F(AudioGraphTest, swapNonAdjacent_Middle) { + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4, 5}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 2 and 4 (Separated by 3) + swapNodes(graph, nodeMap[2], nodeMap[4]); + + verifyGraphOrder(graph, {1, 4, 3, 2, 5}); +} + +TEST_F(AudioGraphTest, swapNonAdjacent_Head_Tail) { + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 1 and 4 + swapNodes(graph, nodeMap[1], nodeMap[4]); + + verifyGraphOrder(graph, {4, 2, 3, 1}); +} + +TEST_F(AudioGraphTest, swapNonAdjacent_Head_Middle) { + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 1 and 3 + swapNodes(graph, nodeMap[1], nodeMap[3]); + + verifyGraphOrder(graph, {3, 2, 1, 4}); +} + +TEST_F(AudioGraphTest, swapNonAdjacent_Middle_Tail) { + auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); + AudioGraph graph; + setGraphHead(graph, headNode); + + // Swap 2 and 4 + swapNodes(graph, nodeMap[2], nodeMap[4]); + + verifyGraphOrder(graph, {1, 4, 3, 2}); +} + +TEST_F(AudioGraphTest, IteratorTest) { + auto [headNode, nodeMap] = createGraphNodes({10, 20, 30}); + AudioGraph graph; + setGraphHead(graph, headNode); + + AudioGraph::Iterator it(getGraphHead(graph)); + + AudioGraph::Node* node = it.next(); + ASSERT_NE(node, nullptr); + EXPECT_EQ(node->test_node_identifier__, 10); + + node = it.next(); + ASSERT_NE(node, nullptr); + EXPECT_EQ(node->test_node_identifier__, 20); + + node = it.next(); + ASSERT_NE(node, nullptr); + EXPECT_EQ(node->test_node_identifier__, 30); + + node = it.next(); + EXPECT_EQ(node, nullptr); +} + +}; // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp index e7f163729..e997b396f 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp @@ -2,8 +2,8 @@ #include - -using namespace audioapi; +#include +#include class SandboxTest : public ::testing::Test { protected: From ee9004543521f5e2b097afe0107cc20bbd88041a Mon Sep 17 00:00:00 2001 From: poneciak Date: Fri, 6 Feb 2026 14:57:52 +0100 Subject: [PATCH 03/11] feat: fat function impl with tests --- .../common/cpp/audioapi/utils/FatFunction.hpp | 170 ++++++++++++++++++ .../common/cpp/test/src/FatFunctionTest.cpp | 158 ++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp new file mode 100644 index 000000000..db34b2be5 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp @@ -0,0 +1,170 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace audioapi { + +template +concept CallableConcept = + (sizeof(std::decay_t<_Callable>) <= N) && requires(_Callable &&_c, _FpArgs &&..._args) { + { _c(std::forward<_FpArgs>(_args)...) } -> std::convertible_to<_FpReturnType>; + }; + +template +class FatFunction; + +/// @brief FatFunction is a fixed-size function wrapper that can store callable objects +/// of a specific size N without dynamic memory allocation. +/// @tparam N Size in bytes to allocate for the callable object +/// @tparam _Fp The function signature (e.g., void(), int(int), etc.) +template +class FatFunction { + template + friend class FatFunction; + + private: + using _InvokerType = _FpReturnType (*)(const std::byte *storage, _FpArgs... args); + using _DeleterType = void (*)(std::byte *storage); + using _MoverType = void (*)(std::byte *dest, std::byte *src); + + public: + FatFunction() = default; + FatFunction(std::nullptr_t) : FatFunction() {} + + /// @brief Constructs a FatFunction from a callable object. + /// @tparam _Callable The type of the callable object + /// @tparam (enable_if) Ensures that the callable fits within the allocated size N + /// and is invocable with the specified signature. + /// @param callable The callable object to store + template + requires CallableConcept + FatFunction(_Callable &&callable) { + using DecayedCallable = std::decay_t<_Callable>; + new (storage_.data()) DecayedCallable(std::forward<_Callable>(callable)); + invoker_ = [](const std::byte *storage, _FpArgs... args) -> _FpReturnType { + const DecayedCallable *callablePtr = reinterpret_cast(storage); + return (*callablePtr)(std::forward<_FpArgs>(args)...); + }; + if constexpr (std::is_trivially_destructible_v) { + // No custom deleter needed for trivially destructible types + deleter_ = nullptr; + } else { + deleter_ = [](std::byte *storage) { + DecayedCallable *callablePtr = reinterpret_cast(storage); + callablePtr->~DecayedCallable(); + }; + } + if constexpr (std::is_trivially_move_constructible_v) { + // No custom mover needed for trivially moveable types as memcpy is a fallback + mover_ = nullptr; + } else { + mover_ = [](std::byte *dest, std::byte *src) { + DecayedCallable *srcPtr = reinterpret_cast(src); + new (dest) DecayedCallable(std::move(*srcPtr)); + }; + } + } + + /// @brief Move constructor + /// @param other The FatFunction to move from + /// @tparam M The size of the callable in the other FatFunction, must be less than or equal to N to ensure it fits in the storage + /// @note We can freely create FatFunction with bigger storage from FatFunction with smaller storage, but not the other way around + FatFunction(FatFunction &&other) noexcept { + if (other.invoker_) { + if (other.mover_) { + other.mover_(storage_.data(), other.storage_.data()); + } else { + std::memcpy(storage_.data(), other.storage_.data(), N); + } + invoker_ = other.invoker_; + deleter_ = other.deleter_; + mover_ = other.mover_; + other.reset(); + } + } + + template + requires(M < N) + FatFunction(FatFunction &&other) { + if (other.invoker_) { + if (other.mover_) { + other.mover_(storage_.data(), other.storage_.data()); + } else { + std::memcpy(storage_.data(), other.storage_.data(), M); + } + invoker_ = other.invoker_; + deleter_ = other.deleter_; + mover_ = other.mover_; + other.reset(); + } + } + + /// @brief Move assignment operator + /// @param other + FatFunction &operator=(FatFunction &&other) noexcept { + // Manual move logic matching constructor + if (other.invoker_) { + if (other.mover_) { + other.mover_(storage_.data(), other.storage_.data()); + } else { + std::memcpy(storage_.data(), other.storage_.data(), N); + } + invoker_ = other.invoker_; + deleter_ = other.deleter_; + mover_ = other.mover_; + other.reset(); + } + return *this; + } + + /// @brief Call operator to invoke the stored callable + /// @param ...args Arguments to pass to the callable + /// @return The result of the callable invocation + _FpReturnType operator()(_FpArgs &&...args) const { + if (!invoker_) { + throw std::bad_function_call(); + } + return invoker_(storage_.data(), std::forward<_FpArgs>(args)...); + } + + /// @brief Destructor + ~FatFunction() { + reset(); + } + + /// @brief Releases the stored callable and returns its storage and deleter. + /// @return A pair containing the storage array and the deleter function + /// @note To clear resources properly after release, the user must call the deleter on the storage. + std::pair, _DeleterType> release() { + std::array storageCopy; + std::memcpy(storageCopy.data(), storage_.data(), N); + _DeleterType deleterCopy = deleter_; + deleter_ = nullptr; + invoker_ = nullptr; + mover_ = nullptr; + return {std::move(storageCopy), deleterCopy}; + } + + private: + alignas(std::max_align_t) std::array storage_; + _InvokerType invoker_ = nullptr; // Function pointer to invoke the stored callable + _DeleterType deleter_ = nullptr; // Function pointer to delete the stored callable + _MoverType mover_ = nullptr; // Function pointer to move the stored callable + + void reset() { + if (deleter_) { + deleter_(storage_.data()); + } + deleter_ = nullptr; + invoker_ = nullptr; + mover_ = nullptr; + } +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp new file mode 100644 index 000000000..76acf2101 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp @@ -0,0 +1,158 @@ + +#include +#include +#include +#include +#include + +using namespace audioapi; + +using IntOp = int(int, int); // NOLINT(readability/casting) + +class FatFunctionTest : public ::testing::Test { + protected: + void SetUp() override { + // Code here will be called immediately after the constructor (right + // before each test). + } + + void TearDown() override { + // Code here will be called immediately after each test (right + // before the destructor). + } +}; + +TEST(FatFunctionTest, BasicFunctionality) { + FatFunction<64, IntOp> add = [](int a, int b) { return a + b; }; + EXPECT_EQ(add(2, 3), 5); + EXPECT_EQ(sizeof(add), 64 + sizeof(void*) * 3); // Storage + function pointers +} + +TEST(FatFunctionTest, MoveSemantics) { + FatFunction<64, IntOp> add = [](int a, int b) { return a + b; }; + FatFunction<64, IntOp> movedAdd = std::move(add); + EXPECT_EQ(movedAdd(4, 5), 9); + EXPECT_THROW(add(1, 2), std::bad_function_call); // Original should be empty +} + +TEST(FatFunctionTest, Release) { + int destructorCalls = 0; + struct Tracked { + int* counter; + explicit Tracked(int* c) : counter(c) {} + int operator()(int a, int b) const { return a + b; } + ~Tracked() { (*counter)++; } + }; + + { + FatFunction<64, IntOp> add = Tracked(&destructorCalls); + // we comment this because compiler can optimize it and do not call destructor here + // EXPECT_EQ(destructorCalls, 1); // Destructor called for the temporary Tracked object, but not for the one inside add + destructorCalls = 0; // Reset counter after construction + auto [storage, deleter] = add.release(); + EXPECT_GT(storage.size(), 0); + EXPECT_NE(deleter, nullptr); + + // We can call the deleter to clean up resources if needed + if (deleter) { + deleter(storage.data()); + } + } // FatFunction goes out of scope here, but it was released so it shouldn't destroy the object again + + EXPECT_EQ(destructorCalls, 1); // Destructor should have been called exactly once from the deleter +} + +TEST(FatFunctionTest, EmptyFunctionCall) { + FatFunction<64, IntOp> emptyFunc; + EXPECT_THROW(emptyFunc(1, 2), std::bad_function_call); +} + +TEST(FatFunctionTest, SwapFunctions) { + FatFunction<64, IntOp> add = [](int a, int b) { return a + b; }; + FatFunction<64, IntOp> multiply = [](int a, int b) { return a * b; }; + + std::swap(add, multiply); + + EXPECT_EQ(add(2, 3), 6); // Now add should multiply + EXPECT_EQ(multiply(2, 3), 5); // Now multiply should add +} + +TEST(FatFunctionTest, LargeCallable) { + struct LargeCallable { + int operator()(int a, int b) const { + return a * b; + } + char data[65]; // This makes it larger than the storage size + }; + + // This should fail to compile because LargeCallable exceeds the storage size + bool isConstructible = std::is_constructible_v, LargeCallable>; + EXPECT_FALSE(isConstructible); +} + +TEST(FatFunctionTest, TriviallyMoveableCallable) { + struct TrivialCallable { + int operator()(int a, int b) const { + return a - b; + } + }; + + FatFunction<64, IntOp> func = TrivialCallable(); + EXPECT_EQ(func(5, 3), 2); +} + +TEST(FatFunctionTest, SmallerToLargerMove) { + FatFunction<32, IntOp> smallFunc = [](int a, int b) { return a + b; }; + FatFunction<64, IntOp> largeFunc = std::move(smallFunc); + EXPECT_EQ(largeFunc(2, 3), 5); + EXPECT_THROW(smallFunc(1, 2), std::bad_function_call); // Original should be empty +} + +TEST(FatFunctionTest, LargerToSmallerMove) { + FatFunction<64, IntOp> largeFunc = [](int a, int b) { return a * b; }; + // This should fail to compile because largeFunc exceeds the storage size of smallFunc + bool isConstructible = std::is_constructible_v, decltype(largeFunc)>; + EXPECT_FALSE(isConstructible); +} + +TEST(FatFunctionTest, SmallerToLargerMoveWithNonTrivialMoveAndDestruct) { + int destructorCalled = 0; + int moverCalled = 0; + + struct NonTrivialCallable { + int* dCounter; + int* mCounter; + + NonTrivialCallable(int* d, int* m) : dCounter(d), mCounter(m) {} + + int operator()(int a, int b) const { + return a + b; + } + ~NonTrivialCallable() { + if (dCounter) (*dCounter)++; + } + NonTrivialCallable(NonTrivialCallable&& other) : dCounter(other.dCounter), mCounter(other.mCounter) { + if (mCounter) (*mCounter)++; + } + NonTrivialCallable(const NonTrivialCallable&) = delete; // Non-copyable + }; + + { + FatFunction<32, IntOp> smallFunc = NonTrivialCallable(&destructorCalled, &moverCalled); + + // Initial construction involves move from temporary + EXPECT_EQ(moverCalled, 1); + EXPECT_EQ(destructorCalled, 1); + + FatFunction<64, IntOp> largeFunc = std::move(smallFunc); + + // Move to largeFunc invokes move ctor + destruction of smallFunc's content + EXPECT_EQ(moverCalled, 2); + EXPECT_EQ(destructorCalled, 2); + + EXPECT_EQ(largeFunc(2, 3), 5); + EXPECT_THROW(smallFunc(1, 2), std::bad_function_call); // Original should be empty + } + EXPECT_EQ(moverCalled, 2); + EXPECT_EQ(destructorCalled, 3); +} From 2a15c079c9505b1418bbeb405d187b9ef0d29e32 Mon Sep 17 00:00:00 2001 From: poneciak Date: Fri, 6 Feb 2026 17:27:22 +0100 Subject: [PATCH 04/11] fix: fixed tests --- .../common/cpp/test/src/FatFunctionTest.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp index 76acf2101..37a4238fe 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp @@ -25,7 +25,11 @@ class FatFunctionTest : public ::testing::Test { TEST(FatFunctionTest, BasicFunctionality) { FatFunction<64, IntOp> add = [](int a, int b) { return a + b; }; EXPECT_EQ(add(2, 3), 5); - EXPECT_EQ(sizeof(add), 64 + sizeof(void*) * 3); // Storage + function pointers + // Account for alignment padding + constexpr size_t dataSize = 64 + sizeof(void*) * 3; + constexpr size_t alignment = alignof(std::max_align_t); + constexpr size_t expectedSize = (dataSize + alignment - 1) & ~(alignment - 1); + EXPECT_EQ(sizeof(add), expectedSize); } TEST(FatFunctionTest, MoveSemantics) { From ec74d137f49e641a2f87d68fc23cecbb04541f41 Mon Sep 17 00:00:00 2001 From: poneciak Date: Mon, 9 Feb 2026 17:28:00 +0100 Subject: [PATCH 05/11] feat: declared api and implemented helpers for testing --- .../audioapi/core/utils/graph/AudioGraph.cpp | 56 ++---- .../audioapi/core/utils/graph/AudioGraph.h | 35 ++-- .../audioapi/core/utils/graph/HostGraph.cpp | 99 +++++++++ .../cpp/audioapi/core/utils/graph/HostGraph.h | 89 ++++++++- .../cpp/test/src/graph/AudioGraphTest.cpp | 188 ------------------ .../cpp/test/src/graph/TestGraphUtils.cpp | 177 +++++++++++++++++ .../cpp/test/src/graph/TestGraphUtils.h | 46 +++++ 7 files changed, 443 insertions(+), 247 deletions(-) delete mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.cpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.h diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp index fb9fb97bd..c92106bb2 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp @@ -3,49 +3,23 @@ namespace audioapi::utils::graph { -void AudioGraph::swapNodesInTopologicalOrder(Node* nodeA, Node* nodeB) { - if (nodeA == nodeB) return; - - bool adjacent = (nodeA->next == nodeB) || (nodeB->next == nodeA); - - if (adjacent) { - Node* first = (nodeA->next == nodeB) ? nodeA : nodeB; - Node* second = (first == nodeA) ? nodeB : nodeA; - - Node* prevFirst = first->prev; - Node* nextSecond = second->next; - - if (prevFirst) prevFirst->next = second; - if (nextSecond) nextSecond->prev = first; - - second->prev = prevFirst; - second->next = first; - first->prev = second; - first->next = nextSecond; - - if (head == first) head = second; - } else { - Node* prevA = nodeA->prev; - Node* nextA = nodeA->next; - Node* prevB = nodeB->prev; - Node* nextB = nodeB->next; - - if (prevA) prevA->next = nodeB; - if (nextA) nextA->prev = nodeB; - - if (prevB) prevB->next = nodeA; - if (nextB) nextB->prev = nodeA; - - nodeA->prev = prevB; - nodeA->next = nextB; - nodeB->prev = prevA; - nodeB->next = nextA; +AudioGraph::AudioGraph() { + head = new Node(); // Create a dummy head node +} - if (head == nodeA) head = nodeB; - else if (head == nodeB) head = nodeA; +AudioGraph::AudioGraph(AudioGraph&& other) noexcept : head(std::exchange(other.head, nullptr)) {} + +AudioGraph& AudioGraph::operator=(AudioGraph&& other) noexcept { + if (this != &other) { + Node* current = head; + while (current) { + Node* nextNode = current->next; + delete current; + current = nextNode; + } + head = std::exchange(other.head, nullptr); } - - std::swap(nodeA->topologicalIndex, nodeB->topologicalIndex); + return *this; } AudioGraph::~AudioGraph() { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h index fd9cba3ef..48691dc6a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h @@ -2,27 +2,22 @@ #include #include #include -#if RN_AUDIO_API_TEST -#include -#endif // RN_AUDIO_API_TEST namespace audioapi::utils::graph { // Forward declarations -#if RN_AUDIO_API_TEST -class AudioGraphTest; -#endif // RN_AUDIO_API_TEST class HostGraph; +class AudioGraph; +class TestGraphUtils; +/// @brief AudioGraph is only a structure allowing topological traversal +/// @note it is fully managed by events provided by HostGraph class AudioGraph { public: struct Node { // std::unique_ptr audioNode; std::vector inputs; - - Node *next = nullptr; // next in topological order - Node *prev = nullptr; // previous in topological order - size_t topologicalIndex = 0; // for swapping + Node *next = nullptr; // next in topological order #if RN_AUDIO_API_TEST // Identifier for testing purposes only @@ -39,22 +34,28 @@ class AudioGraph { Node *current = nullptr; }; - AudioGraph() = default; + AudioGraph(); + + AudioGraph(const AudioGraph &) = delete; + AudioGraph &operator=(const AudioGraph &) = delete; + + AudioGraph(AudioGraph &&other) noexcept; + AudioGraph &operator=(AudioGraph &&other) noexcept; /// @brief Destructor that cleans up all nodes in the graph /// @note Graph owns all of its nodes so they are deleted here ~AudioGraph(); + Iterator iterator() const { + return Iterator(head->next); + } + private: + // Head is a dummy node that helps with events execution without worrying about edge case of node being head. Node *head = nullptr; - void swapNodesInTopologicalOrder(Node *nodeA, Node *nodeB); - -// Granting access -#if RN_AUDIO_API_TEST - friend class AudioGraphTest; -#endif // RN_AUDIO_API_TEST friend class HostGraph; + friend class TestGraphUtils; }; } // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp index a2319a26f..2c615172d 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp @@ -1 +1,100 @@ #include +#include + +namespace audioapi::utils::graph { + +bool HostGraph::TraversalState::visit(size_t currentTerm) { + const bool ret = (term != currentTerm); + term = currentTerm; + return ret; +} + +HostGraph::Node::~Node() { + // Remove this node from its inputs' outputs + for (Node* input : inputs) { + input->outputs.erase(std::remove(input->outputs.begin(), input->outputs.end(), this), input->outputs.end()); + } + // Remove this node from its outputs' inputs + for (Node* output : outputs) { + output->inputs.erase(std::remove(output->inputs.begin(), output->inputs.end(), this), output->inputs.end()); + } + if (prev != nullptr) { + prev->next = next; + } + if (next != nullptr) { + next->prev = prev; + } +} + +HostGraph::HostGraph() { + head = new Node(); // Create a dummy head node + tail = new Node(); // Create a dummy tail node + head->next = tail; + tail->prev = head; +} + +HostGraph::HostGraph(HostGraph&& other) noexcept + : nodes(std::move(other.nodes)), + head(std::exchange(other.head, nullptr)), + tail(std::exchange(other.tail, nullptr)), + last_term(other.last_term) { + other.last_term = 0; +} + +HostGraph& HostGraph::operator=(HostGraph&& other) noexcept { + if (this != &other) { + // Clean up existing resources before overwriting + + // Optimization: clear edges to speed up destruction + for (const auto& nodePtr : nodes) { + if(nodePtr) { + nodePtr->inputs.clear(); + nodePtr->outputs.clear(); + } + } + nodes.clear(); + delete head; + delete tail; + + // Move resources + nodes = std::move(other.nodes); + + head = std::exchange(other.head, nullptr); + tail = std::exchange(other.tail, nullptr); + last_term = other.last_term; + other.last_term = 0; + } + return *this; +} + +HostGraph::~HostGraph() { + // For faster cleanup we will empty out all edges to avoid O(n^2) complexity + for (const auto& nodePtr : nodes) { + if (nodePtr) { + nodePtr->inputs.clear(); + nodePtr->outputs.clear(); + } + } + // Nodes will be automatically deleted by unique_ptr destructors + + delete head; + delete tail; +} + +std::pair HostGraph::addNode(AudioGraph::Node *audioNode) { + return {}; // TODO +} + +HostGraph::AGEvent HostGraph::removeNode(Node *node) { + return {}; // TODO +} + +HostGraph::AGEvent HostGraph::addEdge(Node *from, Node *to) { + return {}; // TODO +} + +HostGraph::AGEvent HostGraph::removeEdge(Node *from, Node *to) { + return {}; // TODO +} + +}; // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h index bc596d59f..97efa64a4 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -1,7 +1,94 @@ #pragma once +#include +#include + +#include +#include + namespace audioapi::utils::graph { -class HostGraph {}; +class HostGraph; +class TestGraphUtils; + +/// @brief HostGraph is responsible for managing the graph structure, including adding/removing nodes and edges, and maintaining the topological order. It should also provide methods for traversing the graph and accessing node information. The HostGraph will interact with the AudioGraph to ensure that the structure of the graph is maintained correctly, and it will handle any necessary updates to the AudioGraph when changes are made to the graph structure in the HostGraph. +/// @note It is intended to be used with caution as it does not give any safety guarantees about consistency and does not check correctness of the AudioGraph referenced in events. It also does not check if Node pointers haven't been deallocated in AudioGraph. So there are a lot of assumptions and please use wrapper +/// @note It is izomorphic to AudioGraph in terms of nodes and edges, but it also maintains additional data for faster operations +class HostGraph { + public: + using AGEvent = FatFunction<32, void()>; + + struct TraversalState { + size_t term = 0; // for classification of temp data as old or new + + /// @brief Visits a node during traversal, marking it as visited and updating the term. This function can be used to track the traversal state of nodes in the graph, allowing for algorithms such as depth-first search (DFS) or breadth-first search (BFS) to be implemented effectively. + /// @param currentTerm The current traversal term to mark the node with. + /// @return true if node was not visited in the current traversal (term), false otherwise + bool visit(size_t currentTerm); + }; + + struct Node { + AudioGraph::Node *audioNode = nullptr; // pointer to the corresponding node in AudioGraph + std::vector inputs; // reversed edges + std::vector outputs; // edges + + Node *next = nullptr; // next in topological order + Node *prev = nullptr; // previous in topological order + size_t topologicalIndex = 0; // for swapping + TraversalState traversalState; // for graph traversals + +#if RN_AUDIO_API_TEST + // Identifier for testing purposes only + size_t test_node_identifier__ = 0; +#endif // RN_AUDIO_API_TEST + + /// @brief Destructor cleans up all edges connected to this node. It removes this node from the inputs and outputs of its neighboring nodes. + /// @note it does NOT destroy corresponding AudioGraph::Node + ~Node(); + }; + + HostGraph(); + ~HostGraph(); + + HostGraph(const HostGraph &) = delete; + HostGraph &operator=(const HostGraph &) = delete; + + HostGraph(HostGraph &&other) noexcept; + HostGraph &operator=(HostGraph &&other) noexcept; + + /// @brief Adds a new node to the graph, corresponding to the given AudioGraph::Node. + /// @param audioNode Pointer to the AudioGraph::Node to be added. + /// @return Pointer to the newly created HostGraph::Node. Returned pointer lifetime is tied to the HostGraph instance. + /// @return An event that should be applied to corresponding AudioGraph to maintain consistency between graphs. The event is a function that takes an AudioGraph reference and modifies it accordingly (e.g., by adding the corresponding AudioGraph::Node). + std::pair addNode(AudioGraph::Node *audioNode); + + /// @brief Removes a node from the graph. + /// @param node Pointer to the HostGraph::Node to be removed. + /// @return A function that, when applied to an AudioGraph, will remove the corresponding AudioGraph::Node. The function is a FatFunction that takes an AudioGraph reference and modifies it accordingly (e.g., by removing the corresponding AudioGraph::Node). + /// @note The returned function should be applied to the corresponding AudioGraph to maintain consistency between the graphs. + AGEvent removeNode(Node *node); + + /// @brief Adds an edge from one node to another in the graph. + /// @param from Pointer to the source HostGraph::Node. + /// @param to Pointer to the destination HostGraph::Node. + /// @return A function that, when applied to an AudioGraph, will add the corresponding edge between the AudioGraph::Nodes. The function is a FatFunction that takes an AudioGraph reference and modifies it accordingly (e.g., by adding the corresponding edge between the AudioGraph::Nodes). + AGEvent addEdge(Node *from, Node *to); + + /// @brief Removes an edge from one node to another in the graph. + /// @param from Pointer to the source HostGraph::Node. + /// @param to Pointer to the destination HostGraph::Node. + /// @return A function that, when applied to an AudioGraph, will remove the corresponding edge between the AudioGraph::Nodes. The function is a FatFunction that takes an AudioGraph reference and modifies it accordingly (e.g., by removing the corresponding edge between the AudioGraph::Nodes). + AGEvent removeEdge(Node *from, Node *to); + + private: + std::vector> nodes; // all nodes in the graph + + // Dummy head and tail are nodes that help with edge cases + Node *head = nullptr; // head of the topologically sorted list of nodes (dummy head) + Node *tail = nullptr; // tail of the topologically sorted list of nodes (dummy tail) + size_t last_term = 0; // for traversal data management + + friend class TestGraphUtils; +}; } // namespace audioapi::utils::graph 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 deleted file mode 100644 index 0e5cdad43..000000000 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp +++ /dev/null @@ -1,188 +0,0 @@ -#include -#include -#include -#include -#include -#include - -namespace audioapi::utils::graph { - -class AudioGraphTest : public ::testing::Test { - protected: - void SetUp() override { - // Code here will be called immediately after the constructor (right - // before each test). - } - - void TearDown() override { - // Code here will be called immediately after each test (right - // before the destructor). - } - - // Helper to create a graph with given identifiers - std::pair> createGraphNodes(std::vector ids) { - if (ids.empty()) return {nullptr, {}}; - - AudioGraph::Node* head = new AudioGraph::Node(); - std::unordered_map nodeMap; - nodeMap[ids[0]] = head; - head->test_node_identifier__ = ids[0]; - AudioGraph::Node* tail = head; - - for (size_t i = 1; i < ids.size(); ++i) { - AudioGraph::Node* newNode = new AudioGraph::Node(); - newNode->test_node_identifier__ = ids[i]; - nodeMap[ids[i]] = newNode; - tail->next = newNode; - newNode->prev = tail; - tail = newNode; - } - - return {head, nodeMap}; - } - - void setGraphHead(AudioGraph& graph, AudioGraph::Node* head) { - graph.head = head; - } - - void swapNodes(AudioGraph& graph, AudioGraph::Node* a, AudioGraph::Node* b) { - graph.swapNodesInTopologicalOrder(a, b); - } - - AudioGraph::Node* getGraphHead(AudioGraph& graph) { - return graph.head; - } - - void verifyGraphOrder(AudioGraph& graph, std::vector expectedIds) { - AudioGraph::Node* current = getGraphHead(graph); - AudioGraph::Node* prev = nullptr; - - for (size_t i = 0; i < expectedIds.size(); ++i) { - size_t id = expectedIds[i]; - ASSERT_NE(current, nullptr) << "Expected node with id " << id << " but reached end of graph at index " << i; - EXPECT_EQ(current->test_node_identifier__, id) << "Mismatch at index " << i; - EXPECT_EQ(current->prev, prev) << "Prev pointer broken at node " << id; - - prev = current; - current = current->next; - } - EXPECT_EQ(current, nullptr) << "Graph has more nodes than expected"; - } -}; - - -TEST_F(AudioGraphTest, swapAdjacent_A_B_Middle) { - // A -> B - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 2 and 3 - swapNodes(graph, nodeMap[2], nodeMap[3]); - - verifyGraphOrder(graph, {1, 3, 2, 4}); -} - -TEST_F(AudioGraphTest, swapAdjacent_B_A_Middle) { - // B -> A (Swapping 2 and 3 by passing (3, 2) instead of (2,3)) - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 3 and 2 (where 2 is before 3 in graph) - // Calling swap with (3, 2) should behave same as (2, 3) effectively swapping their positions - swapNodes(graph, nodeMap[3], nodeMap[2]); - - verifyGraphOrder(graph, {1, 3, 2, 4}); -} - -TEST_F(AudioGraphTest, swapAdjacent_Head_Next) { - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 1 and 2 (Head and Next) - swapNodes(graph, nodeMap[1], nodeMap[2]); - - verifyGraphOrder(graph, {2, 1, 3}); -} - -TEST_F(AudioGraphTest, swapAdjacent_TailPrev_Tail) { - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 2 and 3 (TailPrev and Tail) - swapNodes(graph, nodeMap[2], nodeMap[3]); - - verifyGraphOrder(graph, {1, 3, 2}); -} - -TEST_F(AudioGraphTest, swapNonAdjacent_Middle) { - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4, 5}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 2 and 4 (Separated by 3) - swapNodes(graph, nodeMap[2], nodeMap[4]); - - verifyGraphOrder(graph, {1, 4, 3, 2, 5}); -} - -TEST_F(AudioGraphTest, swapNonAdjacent_Head_Tail) { - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 1 and 4 - swapNodes(graph, nodeMap[1], nodeMap[4]); - - verifyGraphOrder(graph, {4, 2, 3, 1}); -} - -TEST_F(AudioGraphTest, swapNonAdjacent_Head_Middle) { - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 1 and 3 - swapNodes(graph, nodeMap[1], nodeMap[3]); - - verifyGraphOrder(graph, {3, 2, 1, 4}); -} - -TEST_F(AudioGraphTest, swapNonAdjacent_Middle_Tail) { - auto [headNode, nodeMap] = createGraphNodes({1, 2, 3, 4}); - AudioGraph graph; - setGraphHead(graph, headNode); - - // Swap 2 and 4 - swapNodes(graph, nodeMap[2], nodeMap[4]); - - verifyGraphOrder(graph, {1, 4, 3, 2}); -} - -TEST_F(AudioGraphTest, IteratorTest) { - auto [headNode, nodeMap] = createGraphNodes({10, 20, 30}); - AudioGraph graph; - setGraphHead(graph, headNode); - - AudioGraph::Iterator it(getGraphHead(graph)); - - AudioGraph::Node* node = it.next(); - ASSERT_NE(node, nullptr); - EXPECT_EQ(node->test_node_identifier__, 10); - - node = it.next(); - ASSERT_NE(node, nullptr); - EXPECT_EQ(node->test_node_identifier__, 20); - - node = it.next(); - ASSERT_NE(node, nullptr); - EXPECT_EQ(node->test_node_identifier__, 30); - - node = it.next(); - EXPECT_EQ(node, nullptr); -} - -}; // namespace audioapi::utils::graph 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 new file mode 100644 index 000000000..4ffee4850 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.cpp @@ -0,0 +1,177 @@ +#include "TestGraphUtils.h" + +#include +#include +#include +#include + +namespace audioapi::utils::graph { + +std::pair TestGraphUtils::createTestGraph(std::vector> adjacencyList) { + HostGraph hostGraph = makeFromAdjacencyList(adjacencyList); + AudioGraph audioGraph = createAudioGraphFromHostGraph(hostGraph); + return {std::move(audioGraph), std::move(hostGraph)}; +} + +std::vector> TestGraphUtils::convertAudioGraphToAdjacencyList(const AudioGraph &audioGraph) { + std::vector> adjacencyList; + + // First pass: verify we can use test identifiers and determine size + // Since AudioGraph is a linked list, we traverse it. + // Note: head is dummy. + size_t maxId = 0; + bool empty = true; + AudioGraph::Node* current = audioGraph.head->next; + + while (current) { + empty = false; + if (current->test_node_identifier__ >= maxId) { + maxId = current->test_node_identifier__; + } + current = current->next; + } + + if (empty) return {}; + + adjacencyList.resize(maxId + 1); + + current = audioGraph.head->next; + while (current) { + size_t nodeId = current->test_node_identifier__; + for (AudioGraph::Node* input : current->inputs) { + if (input) { + adjacencyList[nodeId].push_back(input->test_node_identifier__); + } + } + // Sort for consistent comparison + std::sort(adjacencyList[nodeId].begin(), adjacencyList[nodeId].end()); + current = current->next; + } + + return adjacencyList; +} + +std::vector> TestGraphUtils::convertHostGraphToAdjacencyList(const HostGraph &hostGraph) { + std::vector> adjacencyList; + if (hostGraph.nodes.empty()) return {}; + + size_t maxId = 0; + for (const auto& node : hostGraph.nodes) { + if (node->test_node_identifier__ > maxId) { + maxId = node->test_node_identifier__; + } + } + + adjacencyList.resize(maxId + 1); + + for (const auto& node : hostGraph.nodes) { + size_t nodeId = node->test_node_identifier__; + for (HostGraph::Node* input : node->inputs) { + if (input) { + adjacencyList[nodeId].push_back(input->test_node_identifier__); + } + } + std::sort(adjacencyList[nodeId].begin(), adjacencyList[nodeId].end()); + } + + return adjacencyList; +} + +HostGraph TestGraphUtils::makeFromAdjacencyList(const std::vector> &adjacencyList) { + HostGraph graph; + // Create nodes + for (size_t i = 0; i < adjacencyList.size(); ++i) { + graph.nodes.emplace_back(std::make_unique()); + graph.nodes.back()->audioNode = new AudioGraph::Node(); // Create corresponding AudioGraph node + graph.nodes.back()->test_node_identifier__ = i; // Set test identifier + graph.nodes.back()->audioNode->test_node_identifier__ = i; // Set test identifier + } + + // Create edges based on adjacency list + for (size_t toIndex = 0; toIndex < adjacencyList.size(); ++toIndex) { + for (size_t fromIndex : adjacencyList[toIndex]) { + if (toIndex < graph.nodes.size() && fromIndex < graph.nodes.size()) { + HostGraph::Node* fromNode = graph.nodes[fromIndex].get(); + HostGraph::Node* toNode = graph.nodes[toIndex].get(); + fromNode->outputs.push_back(toNode); + toNode->inputs.push_back(fromNode); + // Update AudioGraph nodes + if (toNode->audioNode && fromNode->audioNode) { + toNode->audioNode->inputs.push_back(fromNode->audioNode); + } + } + } + } + + size_t term = 1; // for traversal state management + + // This will be naive topological sort but this method is only intended for testing purposes so simplicity is more important than performance here + std::sort(graph.nodes.begin(), graph.nodes.end(), [&term](const std::unique_ptr& a, const std::unique_ptr& b) { + // we should swap if we can reach b from a + std::vector stack = {a.get()}; + + term++; + + while (!stack.empty()) { + HostGraph::Node* current = stack.back(); + stack.pop_back(); + if (current == b.get()) { + return true; + } + if (current->traversalState.visit(term)) { + for (HostGraph::Node* output : current->outputs) { + stack.push_back(output); + } + } + } + return false; // a should not come before b + }); + + graph.last_term = term; + + if (!graph.nodes.empty()) { + graph.head->next = graph.nodes[0].get(); + graph.nodes[0]->prev = graph.head; + for (size_t i = 1; i < graph.nodes.size(); ++i) { + graph.nodes[i-1]->next = graph.nodes[i].get(); + graph.nodes[i]->prev = graph.nodes[i-1].get(); + graph.nodes[i]->topologicalIndex = i; + } + graph.nodes.back()->next = graph.tail; + graph.tail->prev = graph.nodes.back().get(); + } else { + graph.head->next = graph.tail; + graph.tail->prev = graph.head; + } + + return graph; +} + +AudioGraph TestGraphUtils::createAudioGraphFromHostGraph(const HostGraph &hostGraph) { + AudioGraph audioGraph; + HostGraph::Node *current = hostGraph.head->next; + + if (hostGraph.head->next != hostGraph.tail) { + audioGraph.head->next = hostGraph.head->next->audioNode; + } else { + audioGraph.head->next = nullptr; + } + + while (current != hostGraph.tail) { + if (current->audioNode) { + // Reconstruct inputs for AudioGraph::Node + current->audioNode->inputs = std::vector(current->inputs.size()); + for (size_t i = 0; i < current->inputs.size(); ++i) { + current->audioNode->inputs[i] = current->inputs[i]->audioNode; + } + + current->audioNode->next = (current->next != hostGraph.tail) ? current->next->audioNode : nullptr; + } + current = current->next; + } + + return audioGraph; +} + +} // namespace audioapi::utils::graph + 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 new file mode 100644 index 000000000..604d4a9b2 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.h @@ -0,0 +1,46 @@ +#pragma once + +#if !RN_AUDIO_API_TEST +#error "RN_AUDIO_API_TEST must be enabled to use TestGraphUtils" +#define RN_AUDIO_API_TEST true // for intellisense +#endif + +#include +#include +#include +#include + +namespace audioapi::utils::graph { + +class TestGraphUtils { + public: + /// @brief Creates a test graph based on the provided adjacency list. + /// @param adjacencyList The adjacency list representing the connections between nodes in the graph. + /// @return A pair of AudioGraph and HostGraph representing the created test graph. + /// It creates a graph based on simple adjacency list where each index corresponds to a node and the vector at that index contains the indices of its input nodes. The function should construct both the AudioGraph and HostGraph accordingly, ensuring that the relationships between nodes are correctly established in both graphs. + static std::pair createTestGraph( + std::vector> adjacencyList); + + /// @brief Converts the given AudioGraph into an adjacency list representation. + /// @param audioGraph The AudioGraph to be converted. + /// @return An adjacency list representing the connections between nodes in the graph, where each index corresponds + /// @note for equality checks + static std::vector> convertAudioGraphToAdjacencyList( + const AudioGraph &audioGraph); + + /// @brief Converts the given HostGraph into an adjacency list representation. + /// @param hostGraph The HostGraph to be converted. + /// @return An adjacency list representing the connections between nodes in the graph, where each index corresponds to a node and the vector at that index contains the indices of its input nodes. + /// @note for equality checks + static std::vector> convertHostGraphToAdjacencyList( + const HostGraph &hostGraph); + + private: + // Helper function to create a HostGraph from an adjacency list + static HostGraph makeFromAdjacencyList(const std::vector> &adjacencyList); + + // Helper function to create an AudioGraph from a HostGraph + static AudioGraph createAudioGraphFromHostGraph(const HostGraph &hostGraph); +}; + +} // namespace audioapi::utils::graph From bbba7126fbfa23b2785f1220055f71642f511555 Mon Sep 17 00:00:00 2001 From: poneciak Date: Tue, 10 Feb 2026 09:43:10 +0100 Subject: [PATCH 06/11] feat: implemented DSU --- .../common/cpp/audioapi/utils/DSU.hpp | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp new file mode 100644 index 000000000..961729cac --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +namespace audioapi::utils { + +/// @brief Disjoint Set Union (DSU) or Union-Find data structure +/// @details this structure provides efficient find and union operations for managing disjoint sets +class DSU { + public: + explicit DSU(size_t n) : parent(n) { + for (size_t i = 0; i < n; ++i) { + parent[i] = i; + } + } + + size_t find(size_t a) { + if (parent[a] != a) { + parent[a] = find(parent[a]); + } + return parent[a]; + } + + void unite(size_t a, size_t b) { + size_t rootA = find(a); + size_t rootB = find(b); + if (rootA != rootB) { + parent[rootB] = rootA; + } + } + + void reset(size_t n) { + parent.resize(n); + for (size_t i = 0; i < n; ++i) { + parent[i] = i; + } + } + + void add() { + parent.push_back(parent.size()); + } + + private: + std::vector parent; +}; + +} // namespace audioapi::utils From 6f38afd6ac956cd336d4c57f2809ebde7b18cc96 Mon Sep 17 00:00:00 2001 From: poneciak Date: Wed, 11 Feb 2026 16:32:12 +0100 Subject: [PATCH 07/11] feat: implemented graph abstraction --- .../audioapi/core/utils/graph/AudioGraph.cpp | 154 +++++++-- .../audioapi/core/utils/graph/AudioGraph.h | 49 +-- .../audioapi/core/utils/graph/Disposer.hpp | 16 + .../audioapi/core/utils/graph/HostGraph.cpp | 176 +++++++---- .../cpp/audioapi/core/utils/graph/HostGraph.h | 43 +-- .../cpp/test/src/graph/AudioGraphTest.cpp | 102 ++++++ .../cpp/test/src/graph/HostGraphTest.cpp | 298 ++++++++++++++++++ .../cpp/test/src/graph/TestGraphUtils.cpp | 165 ++++------ 8 files changed, 784 insertions(+), 219 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Disposer.hpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/HostGraphTest.cpp diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp index c92106bb2..d489c2391 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp @@ -1,41 +1,155 @@ #include #include +#include namespace audioapi::utils::graph { AudioGraph::AudioGraph() { - head = new Node(); // Create a dummy head node + first_free_slot = 0; } -AudioGraph::AudioGraph(AudioGraph&& other) noexcept : head(std::exchange(other.head, nullptr)) {} +AudioGraph::AudioGraph(AudioGraph&& other) noexcept + : nodes(std::move(other.nodes)), + executionOrder(std::move(other.executionOrder)), + first_free_slot(other.first_free_slot), + isDirty(other.isDirty) { + other.isDirty = false; + other.first_free_slot = 0; +} AudioGraph& AudioGraph::operator=(AudioGraph&& other) noexcept { if (this != &other) { - Node* current = head; - while (current) { - Node* nextNode = current->next; - delete current; - current = nextNode; - } - head = std::exchange(other.head, nullptr); + nodes = std::move(other.nodes); + executionOrder = std::move(other.executionOrder); + isDirty = other.isDirty; + first_free_slot = other.first_free_slot; + other.isDirty = false; + other.first_free_slot = 0; } return *this; } -AudioGraph::~AudioGraph() { - Node* current = head; - while (current) { - Node* nextNode = current->next; - delete current; - current = nextNode; +uint32_t AudioGraph::createNode() { + // Check if we have a free slot + // If first_free_slot is within bounds, it's a hole. + // If first_free_slot == size, we are at end. + if (first_free_slot < static_cast(nodes.size())) { + uint32_t idx = static_cast(first_free_slot); + + // Move head to next + first_free_slot = nodes[idx].next_free_slot; + + // Mark as taken + nodes[idx].next_free_slot = -1; + // Reset state + nodes[idx].inputs.clear(); + nodes[idx].topo_out_degree = 0; + + return idx; } + + // No free slot, append + nodes.emplace_back(); + uint32_t idx = static_cast(nodes.size() - 1); + nodes[idx].next_free_slot = -1; // Active + + // Maintain first_free_slot pointing to the "next potential" (which is now new size) + first_free_slot = static_cast(nodes.size()); + + return idx; +} + +void AudioGraph::releaseNode(uint32_t index) { + if (index >= nodes.size()) return; + + // Check if already free + if (!nodes[index].isActive()) return; + + // Clear data + nodes[index].inputs.clear(); + + // Add to head of free list + nodes[index].next_free_slot = first_free_slot; + first_free_slot = static_cast(index); +} + +void AudioGraph::process() { + if (isDirty) { + recomputeTopologicalOrder(); + isDirty = false; + } + + // Actually execute. For now this is just structure maintenance. + // In a real audio graph, we would do: + // for (uint32_t idx : executionOrder) { + // nodes[idx].process(); + // } } -AudioGraph::Node* AudioGraph::Iterator::next() { - if (!current) return nullptr; - Node* result = current; - current = current->next; - return result; +void AudioGraph::recomputeTopologicalOrder() { + executionOrder.clear(); + if (nodes.empty()) return; + executionOrder.reserve(nodes.size()); + + // Khan's Algorithm (Reverse Mode on Reversed Graph) + // Our graph stores Inputs (Back-Edges). + // Node U stores [V1, V2] meaning V1->U and V2->U. + + // Reverse Kahn: Needs Out-Degree and Backward Edges (V -> U). + + // 1. Compute Out-Degree (Number of Dependents) for each node. + // NOTE: here out degree will always be 0 so we do not need to init it to 0 + + // Iterate all nodes to count usage. + for (const auto& node : nodes) { + if (!node.isActive()) continue; + + for (uint32_t inputIdx : node.inputs) { + if (inputIdx < nodes.size() && nodes[inputIdx].isActive()) { + nodes[inputIdx].topo_out_degree++; + } + } + } + + // 2. Queue of 0-Out-Degree Nodes (Leaves). + // These are nodes that nothing depends on (or final destinations). + // We collect them into `executionOrder` temporarily acting as queue. + size_t queueStart = 0; + + for (size_t i = 0; i < nodes.size(); ++i) { + if (nodes[i].isActive() && nodes[i].topo_out_degree == 0) { + executionOrder.push_back(static_cast(i)); + } + } + + // 3. Process Queue + while (queueStart < executionOrder.size()) { + uint32_t vIdx = executionOrder[queueStart++]; + + // For each node U that is an input of V (U -> V) + // Since we process leaves first (V is a leaf in the remaining sub-graph), + // V is 'satisfied' or 'removed'. We reduce the Out-Degree of U. + + const Node& v = nodes[vIdx]; + for (uint32_t uIdx : v.inputs) { + if (uIdx < nodes.size()) { + Node& u = nodes[uIdx]; + if (u.isActive()) { + if (u.topo_out_degree > 0) { + u.topo_out_degree--; + if (u.topo_out_degree == 0) { + executionOrder.push_back(uIdx); + } + } + } + } + } + } + + // We now have a Reverse Topological Order in `executionOrder`. + // Leaf (Destination) -> Source. + // We want Source -> Destination for execution (Dependency -> Dependent). + std::reverse(executionOrder.begin(), executionOrder.end()); } } // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h index 48691dc6a..72a3cc901 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h @@ -15,26 +15,26 @@ class TestGraphUtils; class AudioGraph { public: struct Node { - // std::unique_ptr audioNode; - std::vector inputs; - Node *next = nullptr; // next in topological order + // std::unique_ptr audioNode; // The actual audio node data, managed externally by HostGraph events + std::vector inputs; // indices of input nodes + uint32_t topo_out_degree = 0; // scratch space for topological sort (used as Out-Degree counter) + + // Memory pool optimization: + // -1 means the slot is taken (node is active). + // otherwise, it points to the next free slot index. + // if equal to nodes.size(), it is the last free slot. + int32_t next_free_slot = -1; #if RN_AUDIO_API_TEST // Identifier for testing purposes only size_t test_node_identifier__ = 0; #endif // RN_AUDIO_API_TEST - }; - struct Iterator { - explicit Iterator(Node *start) : current(start) {} - ~Iterator() = default; - Node *next(); - - private: - Node *current = nullptr; + bool isActive() const { return next_free_slot == -1; } }; AudioGraph(); + ~AudioGraph() = default; AudioGraph(const AudioGraph &) = delete; AudioGraph &operator=(const AudioGraph &) = delete; @@ -42,20 +42,29 @@ class AudioGraph { AudioGraph(AudioGraph &&other) noexcept; AudioGraph &operator=(AudioGraph &&other) noexcept; - /// @brief Destructor that cleans up all nodes in the graph - /// @note Graph owns all of its nodes so they are deleted here - ~AudioGraph(); + // The main storage. Be careful with pointer invalidation if resizing. + std::vector nodes; + std::vector executionOrder; - Iterator iterator() const { - return Iterator(head->next); - } + // Points to the first free slot in the `nodes` vector. + // If equal to nodes.size(), it means there are no free slots (and we should append). + // Implicitly initialized to 0 (logical empty). + int32_t first_free_slot = 0; - private: - // Head is a dummy node that helps with events execution without worrying about edge case of node being head. - Node *head = nullptr; + // Helpers + void markDirty() { isDirty = true; } + void process(); // Recomputes topo order if dirty + // Allocator helpers + uint32_t createNode(); + void releaseNode(uint32_t index); + + private: + bool isDirty = false; friend class HostGraph; friend class TestGraphUtils; + + void recomputeTopologicalOrder(); }; } // namespace audioapi::utils::graph 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/graph/Disposer.hpp new file mode 100644 index 000000000..1c5891416 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Disposer.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace audioapi::utils::graph { + +class Disposer { + public: + virtual ~Disposer() = default; + + /// @brief Disposes the given audio node. + /// @param node Pointer to the AudioGraph::Node to be disposed. + virtual void dispose(AudioGraph::Node* node) = 0; +}; + +} // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp index 2c615172d..40503d3ca 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp @@ -1,100 +1,170 @@ #include +#include +#include #include +#include namespace audioapi::utils::graph { bool HostGraph::TraversalState::visit(size_t currentTerm) { - const bool ret = (term != currentTerm); - term = currentTerm; - return ret; + if (term == currentTerm) return false; + term = currentTerm; + return true; } HostGraph::Node::~Node() { // Remove this node from its inputs' outputs for (Node* input : inputs) { - input->outputs.erase(std::remove(input->outputs.begin(), input->outputs.end(), this), input->outputs.end()); + auto& outs = input->outputs; + outs.erase(std::remove(outs.begin(), outs.end(), this), outs.end()); } // Remove this node from its outputs' inputs for (Node* output : outputs) { - output->inputs.erase(std::remove(output->inputs.begin(), output->inputs.end(), this), output->inputs.end()); - } - if (prev != nullptr) { - prev->next = next; - } - if (next != nullptr) { - next->prev = prev; + auto& inps = output->inputs; + inps.erase(std::remove(inps.begin(), inps.end(), this), inps.end()); } } HostGraph::HostGraph() { - head = new Node(); // Create a dummy head node - tail = new Node(); // Create a dummy tail node - head->next = tail; - tail->prev = head; } HostGraph::HostGraph(HostGraph&& other) noexcept : nodes(std::move(other.nodes)), - head(std::exchange(other.head, nullptr)), - tail(std::exchange(other.tail, nullptr)), last_term(other.last_term) { other.last_term = 0; } HostGraph& HostGraph::operator=(HostGraph&& other) noexcept { if (this != &other) { - // Clean up existing resources before overwriting - - // Optimization: clear edges to speed up destruction - for (const auto& nodePtr : nodes) { - if(nodePtr) { - nodePtr->inputs.clear(); - nodePtr->outputs.clear(); - } - } - nodes.clear(); - delete head; - delete tail; - - // Move resources - nodes = std::move(other.nodes); - - head = std::exchange(other.head, nullptr); - tail = std::exchange(other.tail, nullptr); - last_term = other.last_term; - other.last_term = 0; + for (Node* n : nodes) delete n; + nodes = std::move(other.nodes); + last_term = other.last_term; + other.last_term = 0; } return *this; } HostGraph::~HostGraph() { - // For faster cleanup we will empty out all edges to avoid O(n^2) complexity - for (const auto& nodePtr : nodes) { - if (nodePtr) { - nodePtr->inputs.clear(); - nodePtr->outputs.clear(); - } - } - // Nodes will be automatically deleted by unique_ptr destructors - - delete head; - delete tail; + for (Node* n : nodes) delete n; + nodes.clear(); } -std::pair HostGraph::addNode(AudioGraph::Node *audioNode) { - return {}; // TODO + +std::pair HostGraph::addNode(uint32_t audioNodeIndex) { + Node* newNode = new Node(); + newNode->audioNodeIndex = audioNodeIndex; + nodes.push_back(newNode); + + auto event = [audioNodeIndex](AudioGraph& graph, Disposer&) { + // Ensure the node exists in the AudioGraph if it doesn't already + // This assumes sequential addition logic or that caller handled it. + // If we want to be safe: + if (graph.nodes.size() <= audioNodeIndex) { + graph.nodes.resize(audioNodeIndex + 1); + } + }; + + return {newNode, event}; } HostGraph::AGEvent HostGraph::removeNode(Node *node) { - return {}; // TODO + auto it = std::find(nodes.begin(), nodes.end(), node); + if (it != nodes.end()) { + *it = nodes.back(); + nodes.pop_back(); + } + + uint32_t targetIdx = node->audioNodeIndex; + delete node; + + return [targetIdx](AudioGraph& graph, Disposer&) { + // "Ghost Node" strategy: Clear inputs so it disconnects from graph logic + if (targetIdx < graph.nodes.size()) { + graph.nodes[targetIdx].inputs.clear(); + } + + for (auto& n : graph.nodes) { + if (!n.isActive()) continue; + + auto& inps = n.inputs; + auto removeIt = std::remove(inps.begin(), inps.end(), targetIdx); + if (removeIt != inps.end()) { + inps.erase(removeIt, inps.end()); + } + } + + graph.markDirty(); + }; } HostGraph::AGEvent HostGraph::addEdge(Node *from, Node *to) { - return {}; // TODO + // Check if edge exists + for (Node* out : from->outputs) { + if (out == to) return [](AudioGraph&, Disposer&){}; + } + + // Check for cycle: look for path from 'to' to 'from' + if (hasPath(to, from)) { + return [](AudioGraph&, Disposer&){}; + } + + from->outputs.push_back(to); + to->inputs.push_back(from); + + return [fromIdx = from->audioNodeIndex, toIdx = to->audioNodeIndex](AudioGraph& graph, Disposer&) { + if (toIdx < graph.nodes.size() && fromIdx < graph.nodes.size()) { + graph.nodes[toIdx].inputs.push_back(fromIdx); + graph.markDirty(); + } + }; +} + +bool HostGraph::hasPath(Node* start, Node* end) { + if (start == end) return true; + + last_term++; + size_t term = last_term; + + std::vector stack; + stack.push_back(start); + start->traversalState.term = term; + + while (!stack.empty()) { + Node* curr = stack.back(); + stack.pop_back(); + + if (curr == end) return true; + + for (Node* out : curr->outputs) { + if (out->traversalState.visit(term)) { + stack.push_back(out); + } + } + } + return false; } HostGraph::AGEvent HostGraph::removeEdge(Node *from, Node *to) { - return {}; // TODO + // Check existence + auto itOut = std::find(from->outputs.begin(), from->outputs.end(), to); + if (itOut == from->outputs.end()) return [](AudioGraph&, Disposer&){}; + + auto itIn = std::find(to->inputs.begin(), to->inputs.end(), from); + if (itIn != to->inputs.end()) { + to->inputs.erase(itIn); + } + from->outputs.erase(itOut); + + return [fromIdx = from->audioNodeIndex, toIdx = to->audioNodeIndex](AudioGraph& graph, Disposer&) { + if (toIdx < graph.nodes.size() && fromIdx < graph.nodes.size()) { + auto& inputs = graph.nodes[toIdx].inputs; + auto itIn = std::remove(inputs.begin(), inputs.end(), fromIdx); + if (itIn != inputs.end()) inputs.erase(itIn, inputs.end()); + + graph.markDirty(); + } + }; } }; // namespace audioapi::utils::graph + diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h index 97efa64a4..edf4bb58b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include @@ -16,8 +18,7 @@ class TestGraphUtils; /// @note It is izomorphic to AudioGraph in terms of nodes and edges, but it also maintains additional data for faster operations class HostGraph { public: - using AGEvent = FatFunction<32, void()>; - + using AGEvent = FatFunction<32, void(AudioGraph&, Disposer&)>; // Event that modifies AudioGraph to keep it consistent with HostGraph changes struct TraversalState { size_t term = 0; // for classification of temp data as old or new @@ -28,14 +29,10 @@ class HostGraph { }; struct Node { - AudioGraph::Node *audioNode = nullptr; // pointer to the corresponding node in AudioGraph std::vector inputs; // reversed edges std::vector outputs; // edges - - Node *next = nullptr; // next in topological order - Node *prev = nullptr; // previous in topological order - size_t topologicalIndex = 0; // for swapping TraversalState traversalState; // for graph traversals + uint32_t audioNodeIndex = 0; // index of the corresponding node in AudioGraph #if RN_AUDIO_API_TEST // Identifier for testing purposes only @@ -56,39 +53,29 @@ class HostGraph { HostGraph(HostGraph &&other) noexcept; HostGraph &operator=(HostGraph &&other) noexcept; - /// @brief Adds a new node to the graph, corresponding to the given AudioGraph::Node. - /// @param audioNode Pointer to the AudioGraph::Node to be added. - /// @return Pointer to the newly created HostGraph::Node. Returned pointer lifetime is tied to the HostGraph instance. - /// @return An event that should be applied to corresponding AudioGraph to maintain consistency between graphs. The event is a function that takes an AudioGraph reference and modifies it accordingly (e.g., by adding the corresponding AudioGraph::Node). - std::pair addNode(AudioGraph::Node *audioNode); + /// @brief Adds a new node to the graph. + /// @param audioNodeIndex Index of the AudioGraph::Node. + std::pair addNode(uint32_t audioNodeIndex); /// @brief Removes a node from the graph. - /// @param node Pointer to the HostGraph::Node to be removed. - /// @return A function that, when applied to an AudioGraph, will remove the corresponding AudioGraph::Node. The function is a FatFunction that takes an AudioGraph reference and modifies it accordingly (e.g., by removing the corresponding AudioGraph::Node). - /// @note The returned function should be applied to the corresponding AudioGraph to maintain consistency between the graphs. AGEvent removeNode(Node *node); - /// @brief Adds an edge from one node to another in the graph. - /// @param from Pointer to the source HostGraph::Node. - /// @param to Pointer to the destination HostGraph::Node. - /// @return A function that, when applied to an AudioGraph, will add the corresponding edge between the AudioGraph::Nodes. The function is a FatFunction that takes an AudioGraph reference and modifies it accordingly (e.g., by adding the corresponding edge between the AudioGraph::Nodes). + /// @brief Adds an edge. Checks for cycles using DFS. + /// @return Event or empty if cycle detected. AGEvent addEdge(Node *from, Node *to); - /// @brief Removes an edge from one node to another in the graph. - /// @param from Pointer to the source HostGraph::Node. - /// @param to Pointer to the destination HostGraph::Node. - /// @return A function that, when applied to an AudioGraph, will remove the corresponding edge between the AudioGraph::Nodes. The function is a FatFunction that takes an AudioGraph reference and modifies it accordingly (e.g., by removing the corresponding edge between the AudioGraph::Nodes). + /// @brief Removes an edge. AGEvent removeEdge(Node *from, Node *to); private: - std::vector> nodes; // all nodes in the graph - - // Dummy head and tail are nodes that help with edge cases - Node *head = nullptr; // head of the topologically sorted list of nodes (dummy head) - Node *tail = nullptr; // tail of the topologically sorted list of nodes (dummy tail) + // We own the nodes now + std::vector nodes; size_t last_term = 0; // for traversal data management + bool hasPath(Node* from, Node* to); + friend class TestGraphUtils; + friend class HostGraphTest; }; } // namespace audioapi::utils::graph 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 new file mode 100644 index 000000000..54c5f7d32 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp @@ -0,0 +1,102 @@ +#include +#include +#include +#include + +namespace audioapi::utils::graph { + +class AudioGraphTest : public ::testing::Test { + protected: + AudioGraph graph; +}; + +TEST_F(AudioGraphTest, CreateNode_AllocatesIndices) { + uint32_t n1 = graph.createNode(); + uint32_t n2 = graph.createNode(); + uint32_t n3 = graph.createNode(); + + EXPECT_EQ(n1, 0); + EXPECT_EQ(n2, 1); + EXPECT_EQ(n3, 2); + + EXPECT_EQ(graph.nodes.size(), 3); +} + +TEST_F(AudioGraphTest, ReleaseNode_ReuseIndices) { + uint32_t n0 = graph.createNode(); + uint32_t n1 = graph.createNode(); + uint32_t n2 = graph.createNode(); // size 3 + + graph.releaseNode(n1); // release 1 + // Graph: [0:active, 1:free, 2:active] + + uint32_t n1_reuse = graph.createNode(); + EXPECT_EQ(n1_reuse, 1); // Should reuse 1 + + uint32_t n3 = graph.createNode(); + EXPECT_EQ(n3, 3); // New slot +} + +TEST_F(AudioGraphTest, ReleaseNode_LIFO_Logic) { + // Current impl uses LIFO for free list (adds to head) + uint32_t n0 = graph.createNode(); + uint32_t n1 = graph.createNode(); + uint32_t n2 = graph.createNode(); + + graph.releaseNode(n0); // free list: 0 + graph.releaseNode(n2); // free list: 2 -> 0 + + uint32_t r1 = graph.createNode(); + EXPECT_EQ(r1, 2); // Should get 2 (head) + + uint32_t r2 = graph.createNode(); + EXPECT_EQ(r2, 0); // Should get 0 (next) +} + +TEST_F(AudioGraphTest, TopologicalSort_Simple) { + uint32_t n0 = graph.createNode(); // 0 + uint32_t n1 = graph.createNode(); // 1 + uint32_t n2 = graph.createNode(); // 2 + + // 0 -> 1 -> 2 + // Inputs: 1 needs 0, 2 needs 1 + // Outputs tracking removed from AudioGraph::Node + graph.nodes[n1].inputs.push_back(n0); + + graph.nodes[n2].inputs.push_back(n1); + + graph.markDirty(); + graph.process(); + + const auto& order = graph.executionOrder; + EXPECT_EQ(order.size(), 3); + + // Verify order: 0 before 1, 1 before 2 + auto it0 = std::find(order.begin(), order.end(), n0); + auto it1 = std::find(order.begin(), order.end(), n1); + auto it2 = std::find(order.begin(), order.end(), n2); + + ASSERT_NE(it0, order.end()); + ASSERT_NE(it1, order.end()); + ASSERT_NE(it2, order.end()); + + EXPECT_LT(std::distance(order.begin(), it0), std::distance(order.begin(), it1)); + EXPECT_LT(std::distance(order.begin(), it1), std::distance(order.begin(), it2)); +} + +TEST_F(AudioGraphTest, Process_SkipsFreeNodes) { + uint32_t n0 = graph.createNode(); + uint32_t n1 = graph.createNode(); + + graph.releaseNode(n0); + + graph.markDirty(); + graph.process(); + + const auto& order = graph.executionOrder; + // Should only contain n1 + EXPECT_EQ(order.size(), 1); + EXPECT_EQ(order[0], n1); +} + +} // namespace audioapi::utils::graph 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 new file mode 100644 index 000000000..72e84c80e --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/HostGraphTest.cpp @@ -0,0 +1,298 @@ +#include +#include +#include +#include +#include "TestGraphUtils.h" +#include +#include +#include + +namespace audioapi::utils::graph { + +class MockDisposer : public Disposer { + public: + void dispose(AudioGraph::Node* node) override { + // No-op for vector-based graph. Nodes are managed by pool. + } +}; + +class HostGraphTest : public ::testing::Test { + protected: + void verifyAddEdge(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; + + for (auto* n : hostGraph.nodes) { + if (n->test_node_identifier__ == fromId) fromNode = n; + if (n->test_node_identifier__ == toId) toNode = n; + } + + ASSERT_NE(fromNode, nullptr); + ASSERT_NE(toNode, nullptr); + + // Snapshot pre-state + auto initialAudioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); + + // Action + auto event = hostGraph.addEdge(fromNode, toNode); + + // Verify AudioGraph UNCHANGED + auto intermediateAudioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); + EXPECT_EQ(initialAudioAdj, intermediateAudioAdj) << "AudioGraph changed before event execution"; + + // Perform Event + MockDisposer disposer; + event(audioGraph, disposer); + + // 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"; + EXPECT_EQ(finalHostAdj, expectedAdjacencyList) << "HostGraph does not match expected adjacency list"; + } + + HostGraph::Node* findNode(const HostGraph& hostGraph, size_t id) { + for (auto* n : hostGraph.nodes) { + if (n->test_node_identifier__ == id) return n; + } + return nullptr; + } +}; + +TEST_F(HostGraphTest, AddNode) { + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {1, 2}, // 0 -> 1, 2 + {2}, // 1 -> 2 + {} // 2 + }); + + uint32_t audioNodeIdx = audioGraph.createNode(); + AudioGraph::Node& audioNode = audioGraph.nodes[audioNodeIdx]; + audioNode.test_node_identifier__ = 3; + + auto [hostNode, event] = hostGraph.addNode(audioNodeIdx); + + EXPECT_EQ(hostNode->audioNodeIndex, audioNodeIdx); + hostNode->test_node_identifier__ = 3; // Manually correct testing ID for consistency checking + + EXPECT_EQ(TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph).size(), 4); + + MockDisposer disposer; + event(audioGraph, disposer); + + // Still 4 + auto adj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); + EXPECT_EQ(adj.size(), 4); +} + +TEST_F(HostGraphTest, AddEdge_SimpleForward) { + // 0 -> 1 2 + // Add 1 -> 2 + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {1}, // 0 -> 1 + {}, // 1 + {} // 2 + }); + + verifyAddEdge(hostGraph, audioGraph, 1, 2, { + {1}, // 0 -> 1 + {2}, // 1 -> 2 + {} // 2 + }); +} + +TEST_F(HostGraphTest, AddEdge_SimpleReorder) { + // 0, 1 (Independent, assume 0 creates before 1) + // Add 1 -> 0 + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {}, + {} + }); + + // Depending on implementation, making 1->0 implies 1 must be before 0. + // Initial: 0, 1 (probably) + // Expected: 1 -> 0 + + verifyAddEdge(hostGraph, audioGraph, 1, 0, { + {}, // 0 + {0} // 1 -> 0 + }); +} + +TEST_F(HostGraphTest, AddEdge_TransitiveReorder) { + // 0 -> 1 -> 2, 3 + // initial: 0, 1, 2, 3 (3 is likely last or first? usually makes linear) + // Add 2 -> 3 + // If 3 was before 0 (unlikely if created in order), or 3 was isolated. + // Let's force a scenario where target is 'behind' source in list but valid topological wise. + + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {1}, // 0->1 + {}, // 1 + {} // 2 + }); + + verifyAddEdge(hostGraph, audioGraph, 1, 2, { + {1}, + {2}, + {} + }); +} + +TEST_F(HostGraphTest, AddEdge_BackwardsLinkRequiringSort) { + // 0 -> 1 + // 2 + // Initial order likely: 0, 1, 2. (or 2, 0, 1). + // Add 2 -> 0. + // Ensure 2 becomes before 0. + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {1}, + {}, + {} + }); + + verifyAddEdge(hostGraph, audioGraph, 2, 0, { + {1}, + {}, + {0} + }); +} + +TEST_F(HostGraphTest, AddEdge_diamond) { + // 0, 1, 2, 3 + // 0->1, 0->2. + // Add 1->3. + // Add 2->3. + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {1, 2}, + {}, + {}, + {} + }); + + verifyAddEdge(hostGraph, audioGraph, 1, 3, { + {1, 2}, + {3}, + {}, + {} + }); + + verifyAddEdge(hostGraph, audioGraph, 2, 3, { + {1, 2}, + {3}, + {3}, + {} + }); +} + +TEST_F(HostGraphTest, AddEdge_MultiInputOutput) { + // 0 -> 2 + // 1 -> 2 + // Add 2 -> 3 + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {2}, + {2}, + {}, + {} + }); + + verifyAddEdge(hostGraph, audioGraph, 2, 3, { + {2}, + {2}, + {3}, + {} + }); +} + +TEST_F(HostGraphTest, AddEdge_CycleDetection) { + // 0 -> 1 -> 2 + // Try to add 2 -> 0 (Cycle) + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph({ + {1}, // 0 -> 1 + {2}, // 1 -> 2 + {} // 2 + }); + + auto hostAdjBefore = TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph); + auto audioAdjBefore = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); + + HostGraph::Node* node0 = findNode(hostGraph, 0); + HostGraph::Node* node2 = findNode(hostGraph, 2); + + // Try adding cycle 2->0 + auto event = hostGraph.addEdge(node2, node0); + + // HostGraph should NOT change + auto hostAdjAfter = TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph); + EXPECT_EQ(hostAdjBefore, hostAdjAfter) << "HostGraph modified despite cycle detection"; + + MockDisposer disposer; + // Event should do nothing + event(audioGraph, disposer); + auto audioAdjAfter = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); + EXPECT_EQ(audioAdjBefore, audioAdjAfter) << "AudioGraph modified by event despite cycle detection"; +} + +TEST_F(HostGraphTest, AddEdge_LargeSpecificGraph) { + // Create two separate chains and link them + // Chain 1: 0->1->2->3->4 + // Chain 2: 5->6->7->8->9 + // Add 4->5 + + std::vector> adj(10); + for(int i=0; i<4; ++i) adj[i] = {static_cast(i+1)}; + adj[4] = {}; + for(int i=5; i<9; ++i) adj[i] = {static_cast(i+1)}; + adj[9] = {}; + + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph(adj); + + std::vector> expectedAdj = adj; + expectedAdj[4].push_back(5); + + verifyAddEdge(hostGraph, audioGraph, 4, 5, expectedAdj); +} + +TEST_F(HostGraphTest, AddEdge_GridInterconnect) { + // 0 1 2 + // | | | + // 3 4 5 + // + // Connections: 0->3, 1->4, 2->5 + // Add 3->4 (valid) + // Add 4->2 (valid, requires 4 before 2? No, 1->4->2... so 1 before 2) + + std::vector> adj(6); + adj[0] = {3}; + adj[1] = {4}; + adj[2] = {5}; + + auto [audioGraph, hostGraph] = TestGraphUtils::createTestGraph(adj); + + auto expected = adj; + expected[3].push_back(4); + verifyAddEdge(hostGraph, audioGraph, 3, 4, expected); + + expected[4].push_back(2); + verifyAddEdge(hostGraph, audioGraph, 4, 2, expected); + + // Now we have path 0->3->4->2->5 + // And 1->4->2->5 + // 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); + + auto event = hostGraph.addEdge(node5, node0); + EXPECT_EQ(hostAdjBefore, TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph)); + MockDisposer disposer; + event(audioGraph, disposer); + // Should verify audio graph unchanged too inside verifyAddEdge style checks but here manual: +} + +} // namespace audioapi::utils::graph + 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 4ffee4850..aa1221691 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 @@ -4,6 +4,7 @@ #include #include #include +#include namespace audioapi::utils::graph { @@ -15,37 +16,30 @@ std::pair TestGraphUtils::createTestGraph(std::vector> TestGraphUtils::convertAudioGraphToAdjacencyList(const AudioGraph &audioGraph) { std::vector> adjacencyList; + if (audioGraph.nodes.empty()) return {}; - // First pass: verify we can use test identifiers and determine size - // Since AudioGraph is a linked list, we traverse it. - // Note: head is dummy. size_t maxId = 0; - bool empty = true; - AudioGraph::Node* current = audioGraph.head->next; - - while (current) { - empty = false; - if (current->test_node_identifier__ >= maxId) { - maxId = current->test_node_identifier__; + for (const auto& node : audioGraph.nodes) { + if (node.test_node_identifier__ > maxId) { + maxId = node.test_node_identifier__; } - current = current->next; } - if (empty) return {}; - adjacencyList.resize(maxId + 1); - current = audioGraph.head->next; - while (current) { - size_t nodeId = current->test_node_identifier__; - for (AudioGraph::Node* input : current->inputs) { - if (input) { - adjacencyList[nodeId].push_back(input->test_node_identifier__); - } + for (const auto& node : audioGraph.nodes) { + size_t nodeId = node.test_node_identifier__; + + for (uint32_t inputIdx : node.inputs) { + if (inputIdx < audioGraph.nodes.size()) { + size_t inputId = audioGraph.nodes[inputIdx].test_node_identifier__; + adjacencyList[inputId].push_back(nodeId); + } } - // Sort for consistent comparison - std::sort(adjacencyList[nodeId].begin(), adjacencyList[nodeId].end()); - current = current->next; + } + + for(auto& adj : adjacencyList) { + std::sort(adj.begin(), adj.end()); } return adjacencyList; @@ -56,19 +50,20 @@ std::vector> TestGraphUtils::convertHostGraphToAdjacencyList if (hostGraph.nodes.empty()) return {}; size_t maxId = 0; - for (const auto& node : hostGraph.nodes) { - if (node->test_node_identifier__ > maxId) { - maxId = node->test_node_identifier__; + for (auto* n : hostGraph.nodes) { + if (n->test_node_identifier__ > maxId) { + maxId = n->test_node_identifier__; } } adjacencyList.resize(maxId + 1); - for (const auto& node : hostGraph.nodes) { - size_t nodeId = node->test_node_identifier__; - for (HostGraph::Node* input : node->inputs) { - if (input) { - adjacencyList[nodeId].push_back(input->test_node_identifier__); + for (auto* n : hostGraph.nodes) { + size_t nodeId = n->test_node_identifier__; + // HostGraph nodes have `outputs`. Use them directly. + for (HostGraph::Node* output : n->outputs) { + if (output) { + adjacencyList[nodeId].push_back(output->test_node_identifier__); } } std::sort(adjacencyList[nodeId].begin(), adjacencyList[nodeId].end()); @@ -79,97 +74,71 @@ std::vector> TestGraphUtils::convertHostGraphToAdjacencyList HostGraph TestGraphUtils::makeFromAdjacencyList(const std::vector> &adjacencyList) { HostGraph graph; + // Temporary storage to access nodes by index during construction + std::vector nodesVec; + nodesVec.reserve(adjacencyList.size()); + // Create nodes for (size_t i = 0; i < adjacencyList.size(); ++i) { - graph.nodes.emplace_back(std::make_unique()); - graph.nodes.back()->audioNode = new AudioGraph::Node(); // Create corresponding AudioGraph node - graph.nodes.back()->test_node_identifier__ = i; // Set test identifier - graph.nodes.back()->audioNode->test_node_identifier__ = i; // Set test identifier + HostGraph::Node* node = new HostGraph::Node(); + node->audioNodeIndex = static_cast(i); // Assume 1:1 mapping for test graph + node->test_node_identifier__ = i; // Set test identifier + nodesVec.push_back(node); + graph.nodes.push_back(node); } - // Create edges based on adjacency list - for (size_t toIndex = 0; toIndex < adjacencyList.size(); ++toIndex) { - for (size_t fromIndex : adjacencyList[toIndex]) { - if (toIndex < graph.nodes.size() && fromIndex < graph.nodes.size()) { - HostGraph::Node* fromNode = graph.nodes[fromIndex].get(); - HostGraph::Node* toNode = graph.nodes[toIndex].get(); + // Create edges based on adjacency list where index i contains list of OUTPUTS from i + // adjacencyList[i] = {j, k} means i -> j, i -> k + 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]; + fromNode->outputs.push_back(toNode); toNode->inputs.push_back(fromNode); - // Update AudioGraph nodes - if (toNode->audioNode && fromNode->audioNode) { - toNode->audioNode->inputs.push_back(fromNode->audioNode); - } } } } - size_t term = 1; // for traversal state management - - // This will be naive topological sort but this method is only intended for testing purposes so simplicity is more important than performance here - std::sort(graph.nodes.begin(), graph.nodes.end(), [&term](const std::unique_ptr& a, const std::unique_ptr& b) { - // we should swap if we can reach b from a - std::vector stack = {a.get()}; - - term++; - - while (!stack.empty()) { - HostGraph::Node* current = stack.back(); - stack.pop_back(); - if (current == b.get()) { - return true; - } - if (current->traversalState.visit(term)) { - for (HostGraph::Node* output : current->outputs) { - stack.push_back(output); - } - } - } - return false; // a should not come before b - }); + size_t term = 1; // for retrieval of order graph.last_term = term; - if (!graph.nodes.empty()) { - graph.head->next = graph.nodes[0].get(); - graph.nodes[0]->prev = graph.head; - for (size_t i = 1; i < graph.nodes.size(); ++i) { - graph.nodes[i-1]->next = graph.nodes[i].get(); - graph.nodes[i]->prev = graph.nodes[i-1].get(); - graph.nodes[i]->topologicalIndex = i; - } - graph.nodes.back()->next = graph.tail; - graph.tail->prev = graph.nodes.back().get(); - } else { - graph.head->next = graph.tail; - graph.tail->prev = graph.head; - } - return graph; } AudioGraph TestGraphUtils::createAudioGraphFromHostGraph(const HostGraph &hostGraph) { AudioGraph audioGraph; - HostGraph::Node *current = hostGraph.head->next; - if (hostGraph.head->next != hostGraph.tail) { - audioGraph.head->next = hostGraph.head->next->audioNode; - } else { - audioGraph.head->next = nullptr; + if (hostGraph.nodes.empty()) return audioGraph; + + // Determine size. + // Since we assumed audioNodeIndex is valid and 0-based packed in tests: + size_t maxIdx = 0; + for (auto* n : hostGraph.nodes) { + if (n->audioNodeIndex > maxIdx) maxIdx = n->audioNodeIndex; } - while (current != hostGraph.tail) { - if (current->audioNode) { - // Reconstruct inputs for AudioGraph::Node - current->audioNode->inputs = std::vector(current->inputs.size()); - for (size_t i = 0; i < current->inputs.size(); ++i) { - current->audioNode->inputs[i] = current->inputs[i]->audioNode; - } + audioGraph.nodes.resize(maxIdx + 1); - current->audioNode->next = (current->next != hostGraph.tail) ? current->next->audioNode : nullptr; - } - current = current->next; + // Fill nodes + for (auto* n : hostGraph.nodes) { + auto& audioNode = audioGraph.nodes[n->audioNodeIndex]; + audioNode.test_node_identifier__ = n->test_node_identifier__; + + // Inputs + audioNode.inputs.clear(); + for (HostGraph::Node* input : n->inputs) { + audioNode.inputs.push_back(input->audioNodeIndex); + } } + // We should also run process() to build executionOrder? + // The tests might expect it. + audioGraph.markDirty(); + audioGraph.process(); + return audioGraph; } From 266678dd58f3274ff9be659794b553175c9e28fa Mon Sep 17 00:00:00 2001 From: poneciak Date: Wed, 11 Feb 2026 16:32:42 +0100 Subject: [PATCH 08/11] chore: formatting --- .../cpp/audioapi/core/utils/graph/AudioGraph.h | 8 ++++++-- .../cpp/audioapi/core/utils/graph/Disposer.hpp | 2 +- .../cpp/audioapi/core/utils/graph/HostGraph.h | 17 +++++++++++------ 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h index 72a3cc901..312f95658 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h @@ -30,7 +30,9 @@ class AudioGraph { size_t test_node_identifier__ = 0; #endif // RN_AUDIO_API_TEST - bool isActive() const { return next_free_slot == -1; } + bool isActive() const { + return next_free_slot == -1; + } }; AudioGraph(); @@ -52,7 +54,9 @@ class AudioGraph { int32_t first_free_slot = 0; // Helpers - void markDirty() { isDirty = true; } + void markDirty() { + isDirty = true; + } void process(); // Recomputes topo order if dirty // Allocator helpers 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/graph/Disposer.hpp index 1c5891416..bce0c122d 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/graph/Disposer.hpp @@ -10,7 +10,7 @@ class Disposer { /// @brief Disposes the given audio node. /// @param node Pointer to the AudioGraph::Node to be disposed. - virtual void dispose(AudioGraph::Node* node) = 0; + virtual void dispose(AudioGraph::Node *node) = 0; }; } // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h index edf4bb58b..096558f61 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -18,7 +18,12 @@ class TestGraphUtils; /// @note It is izomorphic to AudioGraph in terms of nodes and edges, but it also maintains additional data for faster operations class HostGraph { public: - using AGEvent = FatFunction<32, void(AudioGraph&, Disposer&)>; // Event that modifies AudioGraph to keep it consistent with HostGraph changes + using AGEvent = FatFunction< + 32, + void( + AudioGraph &, + Disposer + &)>; // Event that modifies AudioGraph to keep it consistent with HostGraph changes struct TraversalState { size_t term = 0; // for classification of temp data as old or new @@ -29,10 +34,10 @@ class HostGraph { }; struct Node { - std::vector inputs; // reversed edges - std::vector outputs; // edges + std::vector inputs; // reversed edges + std::vector outputs; // edges TraversalState traversalState; // for graph traversals - uint32_t audioNodeIndex = 0; // index of the corresponding node in AudioGraph + uint32_t audioNodeIndex = 0; // index of the corresponding node in AudioGraph #if RN_AUDIO_API_TEST // Identifier for testing purposes only @@ -69,10 +74,10 @@ class HostGraph { private: // We own the nodes now - std::vector nodes; + std::vector nodes; size_t last_term = 0; // for traversal data management - bool hasPath(Node* from, Node* to); + bool hasPath(Node *from, Node *to); friend class TestGraphUtils; friend class HostGraphTest; From fef045552042091f0d9296e44da54ee2c25e1bd4 Mon Sep 17 00:00:00 2001 From: poneciak Date: Wed, 11 Feb 2026 16:38:51 +0100 Subject: [PATCH 09/11] chore: removed dsu impl --- .../cpp/audioapi/core/utils/graph/HostGraph.h | 1 - .../common/cpp/audioapi/utils/DSU.hpp | 47 ------------------- 2 files changed, 48 deletions(-) delete mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h index 096558f61..ee57ed506 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -2,7 +2,6 @@ #include #include -#include #include #include diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp deleted file mode 100644 index 961729cac..000000000 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/DSU.hpp +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include - -namespace audioapi::utils { - -/// @brief Disjoint Set Union (DSU) or Union-Find data structure -/// @details this structure provides efficient find and union operations for managing disjoint sets -class DSU { - public: - explicit DSU(size_t n) : parent(n) { - for (size_t i = 0; i < n; ++i) { - parent[i] = i; - } - } - - size_t find(size_t a) { - if (parent[a] != a) { - parent[a] = find(parent[a]); - } - return parent[a]; - } - - void unite(size_t a, size_t b) { - size_t rootA = find(a); - size_t rootB = find(b); - if (rootA != rootB) { - parent[rootB] = rootA; - } - } - - void reset(size_t n) { - parent.resize(n); - for (size_t i = 0; i < n; ++i) { - parent[i] = i; - } - } - - void add() { - parent.push_back(parent.size()); - } - - private: - std::vector parent; -}; - -} // namespace audioapi::utils From cdfa32685934365953dc63d7ce5ab6d0c49f339c Mon Sep 17 00:00:00 2001 From: poneciak Date: Fri, 13 Feb 2026 13:14:28 +0100 Subject: [PATCH 10/11] feat: changed host graph to use result --- .../audioapi/core/utils/graph/HostGraph.cpp | 43 ++++++++++++------- .../cpp/audioapi/core/utils/graph/HostGraph.h | 19 ++++++-- .../common/cpp/audioapi/utils/FatFunction.hpp | 5 +++ .../cpp/test/src/graph/HostGraphTest.cpp | 20 ++++----- 4 files changed, 58 insertions(+), 29 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp index 40503d3ca..18e3a4506 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp @@ -67,17 +67,19 @@ std::pair HostGraph::addNode(uint32_t audi return {newNode, event}; } -HostGraph::AGEvent HostGraph::removeNode(Node *node) { +HostGraph::Res HostGraph::removeNode(Node *node) { auto it = std::find(nodes.begin(), nodes.end(), node); - if (it != nodes.end()) { - *it = nodes.back(); - nodes.pop_back(); + if (it == nodes.end()) { + return Res::Err(ResultError::NODE_NOT_FOUND); } + *it = nodes.back(); + nodes.pop_back(); + uint32_t targetIdx = node->audioNodeIndex; delete node; - return [targetIdx](AudioGraph& graph, Disposer&) { + return Res::Ok([targetIdx](AudioGraph& graph, Disposer&) { // "Ghost Node" strategy: Clear inputs so it disconnects from graph logic if (targetIdx < graph.nodes.size()) { graph.nodes[targetIdx].inputs.clear(); @@ -94,29 +96,35 @@ HostGraph::AGEvent HostGraph::removeNode(Node *node) { } graph.markDirty(); - }; + }); } -HostGraph::AGEvent HostGraph::addEdge(Node *from, Node *to) { +HostGraph::Res HostGraph::addEdge(Node *from, Node *to) { + // Check if nodes exist in graph + 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); + } + // Check if edge exists for (Node* out : from->outputs) { - if (out == to) return [](AudioGraph&, Disposer&){}; + if (out == to) return Res::Err(ResultError::EDGE_ALREADY_EXISTS); } // Check for cycle: look for path from 'to' to 'from' if (hasPath(to, from)) { - return [](AudioGraph&, Disposer&){}; + return Res::Err(ResultError::CYCLE_DETECTED); } from->outputs.push_back(to); to->inputs.push_back(from); - return [fromIdx = from->audioNodeIndex, toIdx = to->audioNodeIndex](AudioGraph& graph, Disposer&) { + return Res::Ok([fromIdx = from->audioNodeIndex, toIdx = to->audioNodeIndex](AudioGraph& graph, Disposer&) { if (toIdx < graph.nodes.size() && fromIdx < graph.nodes.size()) { graph.nodes[toIdx].inputs.push_back(fromIdx); graph.markDirty(); } - }; + }); } bool HostGraph::hasPath(Node* start, Node* end) { @@ -144,10 +152,15 @@ bool HostGraph::hasPath(Node* start, Node* end) { return false; } -HostGraph::AGEvent HostGraph::removeEdge(Node *from, Node *to) { +HostGraph::Res HostGraph::removeEdge(Node *from, Node *to) { // Check existence + 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); + } + auto itOut = std::find(from->outputs.begin(), from->outputs.end(), to); - if (itOut == from->outputs.end()) return [](AudioGraph&, Disposer&){}; + if (itOut == from->outputs.end()) return Res::Err(ResultError::EDGE_NOT_FOUND); auto itIn = std::find(to->inputs.begin(), to->inputs.end(), from); if (itIn != to->inputs.end()) { @@ -155,7 +168,7 @@ HostGraph::AGEvent HostGraph::removeEdge(Node *from, Node *to) { } from->outputs.erase(itOut); - return [fromIdx = from->audioNodeIndex, toIdx = to->audioNodeIndex](AudioGraph& graph, Disposer&) { + return Res::Ok([fromIdx = from->audioNodeIndex, toIdx = to->audioNodeIndex](AudioGraph& graph, Disposer&) { if (toIdx < graph.nodes.size() && fromIdx < graph.nodes.size()) { auto& inputs = graph.nodes[toIdx].inputs; auto itIn = std::remove(inputs.begin(), inputs.end(), fromIdx); @@ -163,7 +176,7 @@ HostGraph::AGEvent HostGraph::removeEdge(Node *from, Node *to) { graph.markDirty(); } - }; + }); } }; // namespace audioapi::utils::graph diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h index ee57ed506..83d34788e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -17,12 +18,22 @@ class TestGraphUtils; /// @note It is izomorphic to AudioGraph in terms of nodes and edges, but it also maintains additional data for faster operations class HostGraph { public: + enum class ResultError { + NODE_NOT_FOUND, + CYCLE_DETECTED, + EDGE_NOT_FOUND, + EDGE_ALREADY_EXISTS, + }; + using AGEvent = FatFunction< 32, void( AudioGraph &, Disposer &)>; // Event that modifies AudioGraph to keep it consistent with HostGraph changes + + using Res = Result; + struct TraversalState { size_t term = 0; // for classification of temp data as old or new @@ -62,14 +73,14 @@ class HostGraph { std::pair addNode(uint32_t audioNodeIndex); /// @brief Removes a node from the graph. - AGEvent removeNode(Node *node); + Res removeNode(Node *node); /// @brief Adds an edge. Checks for cycles using DFS. - /// @return Event or empty if cycle detected. - AGEvent addEdge(Node *from, Node *to); + /// @return Event or error if cycle detected. + Res addEdge(Node *from, Node *to); /// @brief Removes an edge. - AGEvent removeEdge(Node *from, Node *to); + Res removeEdge(Node *from, Node *to); private: // We own the nodes now diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp index db34b2be5..7ac0132f5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp @@ -133,6 +133,11 @@ class FatFunction { return invoker_(storage_.data(), std::forward<_FpArgs>(args)...); } + /// @brief Checks if the FatFunction contains a valid callable + explicit operator bool() const noexcept { + return invoker_ != nullptr; + } + /// @brief Destructor ~FatFunction() { reset(); 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 72e84c80e..0308f3baf 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 @@ -36,7 +36,8 @@ class HostGraphTest : public ::testing::Test { auto initialAudioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); // Action - auto event = hostGraph.addEdge(fromNode, toNode); + auto result = hostGraph.addEdge(fromNode, toNode); + ASSERT_TRUE(result.is_ok()) << "addEdge failed"; // Verify AudioGraph UNCHANGED auto intermediateAudioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); @@ -44,6 +45,7 @@ class HostGraphTest : public ::testing::Test { // Perform Event MockDisposer disposer; + auto event = std::move(result).unwrap(); event(audioGraph, disposer); // Verify AudioGraph UPDATED and CONSISTENT @@ -223,17 +225,17 @@ TEST_F(HostGraphTest, AddEdge_CycleDetection) { HostGraph::Node* node2 = findNode(hostGraph, 2); // Try adding cycle 2->0 - auto event = hostGraph.addEdge(node2, node0); + auto result = hostGraph.addEdge(node2, node0); + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(result.unwrap_err(), HostGraph::ResultError::CYCLE_DETECTED); // HostGraph should NOT change auto hostAdjAfter = TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph); EXPECT_EQ(hostAdjBefore, hostAdjAfter) << "HostGraph modified despite cycle detection"; - MockDisposer disposer; - // Event should do nothing - event(audioGraph, disposer); + // AudioGraph should NOT change (no event executed) auto audioAdjAfter = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); - EXPECT_EQ(audioAdjBefore, audioAdjAfter) << "AudioGraph modified by event despite cycle detection"; + EXPECT_EQ(audioAdjBefore, audioAdjAfter) << "AudioGraph modified"; } TEST_F(HostGraphTest, AddEdge_LargeSpecificGraph) { @@ -287,11 +289,9 @@ TEST_F(HostGraphTest, AddEdge_GridInterconnect) { HostGraph::Node* node5 = findNode(hostGraph, 5); HostGraph::Node* node0 = findNode(hostGraph, 0); - auto event = hostGraph.addEdge(node5, node0); + auto result = hostGraph.addEdge(node5, node0); + EXPECT_TRUE(result.is_err()); EXPECT_EQ(hostAdjBefore, TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph)); - MockDisposer disposer; - event(audioGraph, disposer); - // Should verify audio graph unchanged too inside verifyAddEdge style checks but here manual: } } // namespace audioapi::utils::graph From 3ea1481e922ee406e8ece928c75dccf31229bd73 Mon Sep 17 00:00:00 2001 From: poneciak Date: Fri, 13 Feb 2026 22:22:49 +0100 Subject: [PATCH 11/11] feat: implemented graph wrapper --- .../cpp/audioapi/core/utils/graph/Graph.hpp | 112 ++++++++++++ .../common/cpp/test/src/graph/GraphTest.cpp | 163 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.hpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp 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 new file mode 100644 index 000000000..78a6c6014 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include + +#include +#include + +namespace audioapi::utils::graph { + +class Graph { + using Receiver = audioapi::channels::spsc::Receiver< + HostGraph::AGEvent, + audioapi::channels::spsc::OverflowStrategy::WAIT_ON_FULL, + audioapi::channels::spsc::WaitStrategy::BUSY_LOOP>; + using Sender = audioapi::channels::spsc::Sender< + HostGraph::AGEvent, + audioapi::channels::spsc::OverflowStrategy::WAIT_ON_FULL, + audioapi::channels::spsc::WaitStrategy::BUSY_LOOP>; + + using HNode = HostGraph::Node; + using ANode = AudioGraph::Node; + + public: + using ResultError = HostGraph::ResultError; + using Res = Result; + + Graph(size_t eventQueueCapacity, std::unique_ptr disposer) { + auto [sender, receiver] = audioapi::channels::spsc::channel< + HostGraph::AGEvent, + audioapi::channels::spsc::OverflowStrategy::WAIT_ON_FULL, + audioapi::channels::spsc::WaitStrategy::BUSY_LOOP>(eventQueueCapacity); + eventSender = std::move(sender); + eventReceiver = std::move(receiver); + this->disposer = std::move(disposer); + } + ~Graph() = default; + + /// @brief Processes all scheduled events + /// @note IMPORTANT: Should be called only from the audio thread + void processEvents() { + HostGraph::AGEvent event; + while (eventReceiver.try_receive(event) == audioapi::channels::spsc::ResponseStatus::SUCCESS) { + if (event) { + event(audioGraph, *disposer); + } + } + } + + /// @brief Adds a new node to the graph and returns a pointer to it. + /// @return pointer to the newly added node + /// TODO: in future it will get parameters for node creation + HNode *addNode() { + uint32_t audioNodeIndex = audioGraph.createNode(); + auto [hostNode, event] = hostGraph.addNode(audioNodeIndex); + eventSender.send(std::move(event)); + return hostNode; + } + + /// @brief Removes a node and all its edges from the graph. Does nothing if the node does not exist. + /// @param node pointer to the node to be removed + /// @return Result indicating success or failure (e.g., if node was not found) + /// @note This will also destroy this HostGraph::Node and dealocate its memory, so the pointer will become invalid after this call. Be careful with dangling pointers if you keep references to nodes outside of HostGraph. + Res removeNode(HNode *node) { + return hostGraph.removeNode(node).map([&](HostGraph::AGEvent event) { + eventSender.send(std::move(event)); + return NoneType{}; + }); + } + + /// @brief Adds an edge from `from` to `to` if it does not create a cycle. + /// @param from + /// @param to + /// @return Result indicating success or failure (e.g., if edge would create a cycle) + Res addEdge(HNode *from, HNode *to) { + return hostGraph.addEdge(from, to).map([&](HostGraph::AGEvent event) { + eventSender.send(std::move(event)); + return NoneType{}; + }); + } + + /// @brief Removes an edge from `from` to `to`. Does nothing if the edge does not exist. + /// @param from + /// @param to + /// @return Result indicating success or failure (e.g., if edge was not found) + Res removeEdge(HNode *from, HNode *to) { + return hostGraph.removeEdge(from, to).map([&](HostGraph::AGEvent event) { + eventSender.send(std::move(event)); + return NoneType{}; + }); + } + + private: + // Aligning to cache line size to prevent false sharing between audio and main thread + alignas(64) AudioGraph audioGraph; + alignas(64) HostGraph hostGraph; + alignas(64) std::unique_ptr disposer; + + // These are const and their memory won't be modified after initialization, so no false sharing here + Sender eventSender; + Receiver eventReceiver; + + friend class GraphTest; +}; + +} // namespace audioapi::utils::graph 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 new file mode 100644 index 000000000..ebedfab6d --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphTest.cpp @@ -0,0 +1,163 @@ +#include +#include +#include +#include "TestGraphUtils.h" +#include +#include +#include +#include +#include +#include +#include + +namespace audioapi::utils::graph { + +class MockDisposer : public Disposer { + public: + void dispose(AudioGraph::Node* node) override { + // No-op + } +}; + +class GraphTest : public ::testing::Test { + protected: + std::unique_ptr graph; + + void SetUp() override { + graph = std::make_unique(4096, std::make_unique()); + } + + const AudioGraph& getAudioGraph() { + return graph->audioGraph; + } + + const HostGraph& getHostGraph() { + return graph->hostGraph; + } +}; + +TEST_F(GraphTest, EventsAreScheduledButNotExecutedUntilProcess) { + auto* node = graph->addNode(); + ASSERT_NE(node, nullptr); + + // AudioGraph should not be aware of the node structure update yet + // Assuming createNode just reserves index but doesn't resize vector which is handled by event + const auto& ag = getAudioGraph(); + + size_t sizeBefore = ag.nodes.size(); + + // Check if empty/smaller + if (ag.nodes.empty()) { + EXPECT_EQ(ag.nodes.size(), 0); + } + + graph->processEvents(); + + EXPECT_GE(ag.nodes.size(), node->audioNodeIndex + 1); +} + +TEST_F(GraphTest, NoUselessEventsScheduled) { + auto* node1 = graph->addNode(); + auto* node2 = graph->addNode(); + graph->processEvents(); + + // Initial state + const auto& ag = getAudioGraph(); + // Convert to verify + auto initialAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(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 + graph->processEvents(); + + // Result of valid op + auto intermediateAdj = 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); + + // 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)); + EXPECT_EQ(intermediateAdj, finalAdj); +} + +TEST_F(GraphTest, ThreadRaceConcurrency) { + // Stress test with threads + // One thread produces graph changes + // One thread processes events (consumer) + + std::atomic running{true}; + std::vector nodes; + + // Add initial nodes + for(int i=0; i<10; ++i) { + nodes.push_back(graph->addNode()); + } + graph->processEvents(); // Setup + + std::thread audioThread([&]() { + while(running) { + graph->processEvents(); + std::this_thread::sleep_for(std::chrono::microseconds(10)); + } + graph->processEvents(); + }); + + // Main thread mutations + unsigned int seed = 12345; + for(int i=0; i<100; ++i) { + // Randomly add edge or remove edge or add node + int op = rand_r(&seed) % 3; + if (op == 0) { + auto* n = graph->addNode(); + nodes.push_back(n); + } else if (op == 1 && nodes.size() > 2) { + // Add edge + HostGraph::Node *n1, *n2; + { + n1 = nodes[rand_r(&seed) % nodes.size()]; + n2 = nodes[rand_r(&seed) % nodes.size()]; + } + if (n1 != n2) { + // Ignore result (could be error if edge exists or cycle) + (void)graph->addEdge(n1, n2); + } + } else if (op == 2 && nodes.size() > 5) { + // Remove edge + HostGraph::Node *n1, *n2; + { + n1 = nodes[rand_r(&seed) % nodes.size()]; + n2 = nodes[rand_r(&seed) % nodes.size()]; + } + (void)graph->removeEdge(n1, n2); + } + std::this_thread::sleep_for(std::chrono::microseconds(100)); + } + + running = false; + audioThread.join(); + + // If we reached here without crash (segfault), we are somewhat good. + // Verify graph consistency (Host vs Audio) + { + // Must wait for all events to flush - already done by final processEvents in thread, + // but let's do one more to be sure + graph->processEvents(); + + const auto& ag = getAudioGraph(); + const auto& hg = getHostGraph(); + + auto audioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(const_cast(ag)); + auto hostAdj = TestGraphUtils::convertHostGraphToAdjacencyList(const_cast(hg)); + + // They should match + EXPECT_EQ(audioAdj, hostAdj); + } +} + +} // namespace audioapi::utils::graph