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..d489c2391 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.cpp @@ -0,0 +1,155 @@ +#include +#include +#include + +namespace audioapi::utils::graph { + +AudioGraph::AudioGraph() { + first_free_slot = 0; +} + +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) { + 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; +} + +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(); + // } +} + +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 new file mode 100644 index 000000000..312f95658 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/AudioGraph.h @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#include + +namespace audioapi::utils::graph { + +// Forward declarations +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; // 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 + + bool isActive() const { + return next_free_slot == -1; + } + }; + + AudioGraph(); + ~AudioGraph() = default; + + AudioGraph(const AudioGraph &) = delete; + AudioGraph &operator=(const AudioGraph &) = delete; + + AudioGraph(AudioGraph &&other) noexcept; + AudioGraph &operator=(AudioGraph &&other) noexcept; + + // The main storage. Be careful with pointer invalidation if resizing. + std::vector nodes; + std::vector executionOrder; + + // 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; + + // 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..bce0c122d --- /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/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/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..18e3a4506 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.cpp @@ -0,0 +1,183 @@ +#include +#include +#include +#include +#include + +namespace audioapi::utils::graph { + +bool HostGraph::TraversalState::visit(size_t currentTerm) { + if (term == currentTerm) return false; + term = currentTerm; + return true; +} + +HostGraph::Node::~Node() { + // Remove this node from its inputs' outputs + for (Node* input : inputs) { + 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) { + auto& inps = output->inputs; + inps.erase(std::remove(inps.begin(), inps.end(), this), inps.end()); + } +} + +HostGraph::HostGraph() { +} + +HostGraph::HostGraph(HostGraph&& other) noexcept + : nodes(std::move(other.nodes)), + last_term(other.last_term) { + other.last_term = 0; +} + +HostGraph& HostGraph::operator=(HostGraph&& other) noexcept { + if (this != &other) { + 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 (Node* n : nodes) delete n; + nodes.clear(); +} + + +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::Res HostGraph::removeNode(Node *node) { + auto it = std::find(nodes.begin(), nodes.end(), node); + 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 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(); + } + + 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::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 Res::Err(ResultError::EDGE_ALREADY_EXISTS); + } + + // Check for cycle: look for path from 'to' to 'from' + if (hasPath(to, from)) { + return Res::Err(ResultError::CYCLE_DETECTED); + } + + from->outputs.push_back(to); + to->inputs.push_back(from); + + 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) { + 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::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 Res::Err(ResultError::EDGE_NOT_FOUND); + + 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 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); + 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 new file mode 100644 index 000000000..83d34788e --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/HostGraph.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace audioapi::utils::graph { + +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: + 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 + + /// @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 { + 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 + +#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. + /// @param audioNodeIndex Index of the AudioGraph::Node. + std::pair addNode(uint32_t audioNodeIndex); + + /// @brief Removes a node from the graph. + Res removeNode(Node *node); + + /// @brief Adds an edge. Checks for cycles using DFS. + /// @return Event or error if cycle detected. + Res addEdge(Node *from, Node *to); + + /// @brief Removes an edge. + Res removeEdge(Node *from, Node *to); + + private: + // 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/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/audioapi/utils/FatFunction.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp new file mode 100644 index 000000000..7ac0132f5 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/FatFunction.hpp @@ -0,0 +1,175 @@ +#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 Checks if the FatFunction contains a valid callable + explicit operator bool() const noexcept { + return invoker_ != nullptr; + } + + /// @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..37a4238fe --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/FatFunctionTest.cpp @@ -0,0 +1,162 @@ + +#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); + // 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) { + 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); +} 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/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 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..0308f3baf --- /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 result = hostGraph.addEdge(fromNode, toNode); + ASSERT_TRUE(result.is_ok()) << "addEdge failed"; + + // Verify AudioGraph UNCHANGED + auto intermediateAudioAdj = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); + EXPECT_EQ(initialAudioAdj, intermediateAudioAdj) << "AudioGraph changed before event execution"; + + // Perform Event + MockDisposer disposer; + auto event = std::move(result).unwrap(); + 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 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"; + + // AudioGraph should NOT change (no event executed) + auto audioAdjAfter = TestGraphUtils::convertAudioGraphToAdjacencyList(audioGraph); + EXPECT_EQ(audioAdjBefore, audioAdjAfter) << "AudioGraph modified"; +} + +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 result = hostGraph.addEdge(node5, node0); + EXPECT_TRUE(result.is_err()); + EXPECT_EQ(hostAdjBefore, TestGraphUtils::convertHostGraphToAdjacencyList(hostGraph)); +} + +} // 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 new file mode 100644 index 000000000..e997b396f --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/SandboxTest.cpp @@ -0,0 +1,24 @@ + + + +#include +#include +#include + +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); +} 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..aa1221691 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/TestGraphUtils.cpp @@ -0,0 +1,146 @@ +#include "TestGraphUtils.h" + +#include +#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; + if (audioGraph.nodes.empty()) return {}; + + size_t maxId = 0; + for (const auto& node : audioGraph.nodes) { + if (node.test_node_identifier__ > maxId) { + maxId = node.test_node_identifier__; + } + } + + adjacencyList.resize(maxId + 1); + + 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); + } + } + } + + for(auto& adj : adjacencyList) { + std::sort(adj.begin(), adj.end()); + } + + return adjacencyList; +} + +std::vector> TestGraphUtils::convertHostGraphToAdjacencyList(const HostGraph &hostGraph) { + std::vector> adjacencyList; + if (hostGraph.nodes.empty()) return {}; + + size_t maxId = 0; + for (auto* n : hostGraph.nodes) { + if (n->test_node_identifier__ > maxId) { + maxId = n->test_node_identifier__; + } + } + + adjacencyList.resize(maxId + 1); + + 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()); + } + + return adjacencyList; +} + +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) { + 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 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); + } + } + } + + size_t term = 1; // for retrieval of order + + graph.last_term = term; + + return graph; +} + +AudioGraph TestGraphUtils::createAudioGraphFromHostGraph(const HostGraph &hostGraph) { + AudioGraph audioGraph; + + 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; + } + + audioGraph.nodes.resize(maxIdx + 1); + + // 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; +} + +} // 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