From 1944eda0bd981f19a1fe6ec8c12e42e18b41b23e Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 22 Sep 2026 13:41:42 +0200 Subject: [PATCH] refactor: audio graph improvements --- .claude/skills/audio-nodes/SKILL.md | 6 +- .claude/skills/thread-safety-itc/SKILL.md | 2 +- .../docs/graph/graph-implementation.mdx | 441 ++++++++++++++++++ .../HostObjects/AudioNodeHostObject.h | 4 +- .../audioapi/core/utils/graph/AudioGraph.cpp | 211 ++++----- .../audioapi/core/utils/graph/AudioGraph.h | 68 ++- .../cpp/audioapi/core/utils/graph/Graph.cpp | 2 +- .../cpp/audioapi/core/utils/graph/Graph.h | 3 +- .../audioapi/core/utils/graph/GraphObject.h | 2 +- .../audioapi/core/utils/graph/HostGraph.cpp | 6 - .../cpp/test/src/graph/AudioGraphFuzzTest.cpp | 6 +- .../cpp/test/src/graph/AudioGraphTest.cpp | 46 +- .../cpp/test/src/graph/BridgeNodeTest.cpp | 18 +- .../test/src/graph/GraphCycleDebugTest.cpp | 2 +- .../test/src/graph/SettleProcessableTest.cpp | 2 +- 15 files changed, 627 insertions(+), 192 deletions(-) create mode 100644 packages/internaldocs/docs/graph/graph-implementation.mdx diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index 39300f21c..81dcf8c87 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -93,7 +93,7 @@ std::shared_ptr GainNode::processNode( --- -## Processable State (reverse-topo pull) +## Processable State (seed-driven dependency pull) Which nodes run each render quantum is decided by `AudioGraph::settleProcessableState()`, run inside `Graph::process()` after toposort/compaction and before the forward `iter()` pass. It is an **audio-thread-only** concern — never derived from HostGraph adjacency (that mutates on the JS thread under `nodesMutex_`). @@ -103,7 +103,7 @@ Which nodes run each render quantum is decided by `AudioGraph::settleProcessable - `NOT_PROCESSABLE` — idle / disconnected / default. Settle algorithm (allocation-free): -1. **Reverse pull**: walk the topo-sorted node array sinks → sources; for every `ALWAYS_`/`CONDITIONAL_PROCESSABLE` node, mark its inputs (and processable-links) `CONDITIONAL_PROCESSABLE`. Iterates to a fixpoint for processable-links. +1. **Dependency pull**: depth-first from every `ALWAYS_`/`CONDITIONAL_PROCESSABLE` seed, mark each dependency (audio inputs and processable-links alike) `CONDITIONAL_PROCESSABLE` and continue from it. Uses `target_index` as an embedded stack and the state as the visited marker, so it is a single O(V+E) pass independent of array order. 2. **End-of-quantum demotion**: after `processInputs()`, each node that was `CONDITIONAL_PROCESSABLE` flips back to `NOT_PROCESSABLE` in `GraphObject::process()`. That replaces a global reset at the start of settle — nodes that ran last quantum are already idle when the next pull begins. Key invariants: @@ -352,7 +352,7 @@ These are mutable after construction. `AudioNode` (core) exposes virtual `setCha ### Idle-node stale-buffer zeroing (settleProcessableState) `AudioGraph::iter()` filters to `isProcessable()` nodes, so a node that has gone idle (e.g. a finished source) is skipped and its output buffer is NOT refreshed — it keeps the samples from an earlier quantum. Downstream consumers still read that buffer via `getOutput()` when collecting inputs, which would re-sum ghost echoes every quantum (this broke the `audionode-channel-rules` ~170-node WPT test). -Fix: after the reverse-topo pull in `AudioGraph::settleProcessableState()`, zero the output buffer of every node that is still `!isProcessable()`. Active CONDITIONAL nodes have already been pulled, so they are left intact; tail-bearing nodes remain `isProcessable()` while draining and are also left intact. +Fix: after the dependency pull in `AudioGraph::settleProcessableState()`, zero the output buffer of every node that is still `!isProcessable()`. Active CONDITIONAL nodes have already been pulled, so they are left intact; tail-bearing nodes remain `isProcessable()` while draining and are also left intact. Do **not** gate `GraphObject::process()` on `isProcessable()` of inputs: CONDITIONAL nodes demote themselves to `NOT_PROCESSABLE` at the end of their own `process()` call, before downstream consumers run in the same topological pass — an `isProcessable()` gate would drop every live conditional input every quantum. diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 83a029480..c5b7c65e8 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -115,7 +115,7 @@ Do not call `AudioGraphManager` directly — go through `AudioNode::connect()` / ### Processable state is audio-thread-only -Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is derived exclusively on the audio thread by `AudioGraph::settleProcessableState()` (a reverse-topological pull run inside `Graph::process()`), using only audio-thread-owned data: the topo-sorted node array, `InputPool` input lists, and `link_head` processable-links. +Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is derived exclusively on the audio thread by `AudioGraph::settleProcessableState()` (a seed-driven dependency pull run inside `Graph::process()`), using only audio-thread-owned data: the topo-sorted node array, `InputPool` input lists, and `link_head` processable-links. **Pitfall (fixed):** earlier, `HostGraph` AGEvents (`addEdge`/`removeEdge`/`removeAllEdges`) walked `HostGraph::Node::{inputs,outputs,linkedNodes}` on the audio thread to mark processable state incrementally. Those vectors mutate on the JS thread under `nodesMutex_` — a cross-thread race. AGEvents must never read HostGraph adjacency for processable state; they only mirror structural edges/links onto the audio graph. The host-side `linkedNodes` list is now kept solely so links can be scrubbed when a linked node is disposed. diff --git a/packages/internaldocs/docs/graph/graph-implementation.mdx b/packages/internaldocs/docs/graph/graph-implementation.mdx new file mode 100644 index 000000000..42cd6d8c4 --- /dev/null +++ b/packages/internaldocs/docs/graph/graph-implementation.mdx @@ -0,0 +1,441 @@ +--- +sidebar_position: 4 +--- + +# Graph implementation + +[Processing model](./processing-model.mdx) explains why the graph exists twice and what each copy is for. This page is about how the +two copies are built. It names the classes, because the point is to make the code readable. Everything lives in +[`core/utils/graph/`](https://github.com/software-mansion/react-native-audio-api/tree/1966b4ac91efb990f1bc626fdb8899b034b770f4/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph). + +| Class | Thread | Role | +|---|---|---| +| `Graph` | both | The facade. Owns the other two, the event channels and the pre-growth bookkeeping. | +| `HostGraph` | JS | Adjacency lists, validation, ghost nodes. Every mutation returns an event for the audio side. | +| `AudioGraph` | audio | Flat, topologically sorted node array. Toposort, compaction, processable-state settling, iteration. | +| `InputPool` | audio | Slot pool backing every per-node list in `AudioGraph`. | +| `NodeHandle` | shared | The one object both graphs point at: the `GraphObject` and its current index in `AudioGraph`. | +| `GraphObject` | audio | What a node is from the graph's point of view: `process()`, an output buffer, processable state. `AudioNode` derives from it. | + +## Node handle + +```cpp +struct NodeHandle { + std::unique_ptr audioNode; + std::uint32_t index; // position in AudioGraph::nodes, audio thread writes it +}; +``` + +A handle is created on the JS thread with `std::make_shared` and stored in both `HostGraph::Node` and `AudioGraph::Node`, so its reference +count is 2 for as long as the node is alive in both graphs. `AudioGraph` sorts and compacts its array freely and rewrites `index` after every +move, which is how an event built on the JS thread (`graph[handle->index]`) finds the right node when it runs on the audio thread. `index` +is never read on the JS thread. + +The count going from 2 to 1 is the signal that the audio thread has dropped the node. Nothing else is sent; the host side polls +`use_count() == 1` when it is convenient (see [Ghost collection](#ghost-collection)). + +```mermaid +flowchart LR + subgraph JS["JS thread"] + HN["HostGraph::Node
inputs / outputs
ghost"] + end + subgraph shared["shared_ptr, use_count = 2"] + H["NodeHandle
audioNode: unique_ptr<GraphObject>
index: uint32"] + end + subgraph audio["audio thread"] + AN["AudioGraph::Node
input_head / link_head
orphaned"] + end + HN --> H + AN --> H + AN -. "rewrites index
after every move" .-> H +``` + +A node's life, seen through the handle's reference count: + +```mermaid +stateDiagram-v2 + direction LR + Alive: Alive (use_count 2) + Ghost: Ghost (use_count 2, orphaned) + Dropped: Dropped (use_count 1) + [*] --> Alive: addNode on both graphs + Alive --> Ghost: JS releases the node + Ghost --> Dropped: compaction removes it + Dropped --> [*]: collectDisposedNodes deletes it +``` + +The audio thread decides *when* (a node may be a ghost for a long time if it is still playing), and the JS thread does the deleting. + +## Host graph + +`HostGraph::Node` is a plain adjacency-list node: + +```cpp +struct Node { + std::vector inputs; // reversed edges + std::vector outputs; // forward edges + std::vector linkedNodes; // processable-state links, see below + TraversalState traversalState; // DFS visit stamp + ChannelLayoutState channelLayout; // pending channel-count negotiation + std::shared_ptr handle; + bool ghost = false; +}; +``` + +All nodes are heap-allocated and held in a `std::vector`, so pointers stay valid across mutations; `Graph::addNode` returns the raw +pointer, and the `HostNode` RAII wrapper keeps it until destruction. Every public method takes `nodesMutex_`. That mutex is JS-side only: it +serializes the JS thread against the GC finalizer thread, which also calls `removeNode`. The audio thread never touches `HostGraph`. + +### Validation + +`addEdge(from, to)` rejects, in this order: a node that is not in the graph, a ghost on either end, an edge that already exists, and a cycle. +The cycle check is `hasPath(to, from)`: a DFS over `outputs` that would find a path from `to` back to `from`. Instead of a visited set, each +node carries a `term` stamp and the graph has a monotonically increasing counter, so a traversal marks a node visited by writing the current +term and no per-traversal storage is cleared. The DFS uses an explicit stack (`std::vector`), which allocates, and that is fine on the JS thread. + +### Events + +Every mutation returns an `AGEvent`: a closure that captures node handles and applies the same structural change on the audio side by +index. `addNode` captures nothing but the handle and appends it; `removeNode` flips one flag; `addEdge` pushes one pool slot and marks the +order dirty. That structural part is small and the same shape for every mutation. + +What varies is the memory a mutation needs the audio thread to start using. The rule from the processing model applies here: anything that +has to be allocated is allocated on the JS thread while the event is being built, captured by pointer, and swapped in when the event runs. +Which allocations a given event carries depends on what it changes: + +- A new edge may leave the destination's input-scratch vector too small, so `addEdge` ships a replacement reserved to the new input count. + `addNode` and `removeNode` touch no lists and ship none. +- A connection or disconnection can change channel counts along the path toward the destination. The JS thread negotiates the new widths and + ships a fresh render buffer for every node whose width actually changed; a connection that alters no layout ships an empty batch, and a + plain `addNode` does not negotiate at all. + +Whatever the audio thread replaces this way, an old scratch vector or an old render buffer, is handed to the disposer from inside the event, +so the audio thread never frees it. The `addEdge` event shows all of that in one place: + +```cpp +[hTo, hFrom, negotiations, reservedInputs](AudioGraph &graph, auto &disposer) mutable { + applyChannelNegotiations(*negotiations, disposer); // swap in new render buffers, dispose old ones + disposer.dispose(std::move(negotiations)); + disposer.dispose(toNode->exchangeInputScratch(std::move(*reservedInputs))); + disposer.dispose(std::move(reservedInputs)); + graph.pool().push(graph[hTo->index].input_head, hFrom->index); // the structural change itself + graph.markDirty(); +} +``` + +### Ghosts + +`removeNode` does not delete anything. It flips `ghost = true` and returns an event that sets `orphaned = true` on the audio node. The ghost +keeps its `inputs` and `outputs`, so `hasPath` still walks through it. That matters because the node is still alive in `AudioGraph`, where its +edges still exist, and a cycle that goes through a ghost is a real cycle on the audio thread. `addEdge` and `removeEdge` refuse ghosts as +endpoints, but traverse them. + +### Ghost collection + +`collectDisposedNodes` scans for ghosts whose handle has `use_count() == 1`, removes them from the vector (swap with last, pop), and +`delete`s them. `Node::~Node` unlinks the node from every neighbour's `inputs` and `outputs`. This runs on the JS side, so `delete` is fine. +It is called from `HostNode::~HostNode` just before `removeNode`, and from the context's promise worker before a lifecycle operation, so +ghosts are collected as a side effect of the next node going away rather than on a timer. + +## Audio graph + +`AudioGraph::Node` holds no pointers to other nodes, only indices, and packs its scratch fields into bitfields: + +```cpp +struct Node { + std::shared_ptr handle; + std::uint32_t input_head = kNull; // head of this node's input list in the pool + std::uint32_t link_head = kNull; // head of this node's processable-link list + std::uint32_t topo_out_degree : 31; // scratch, toposort + unsigned will_be_deleted : 1; // scratch, compaction + std::int32_t target_index : 31; // scratch, where the node moves to; ready-stack link during the sort + unsigned orphaned : 1; // set by the removeNode event +}; +``` + +The nodes live in a `std::vector` that is kept in topological order, sources first, sinks last. Three properties follow: + +- **An input always has a lower index than its consumer.** A forward pass renders each node after everything it reads from. +- **Rendering is one linear walk** over contiguous memory. `iter()` filters out non-processable nodes and yields each node with a view of its + inputs, resolved from indices to `GraphObject &` on the fly. +- **Indices are unstable.** Both sorting and compaction move nodes. Anything that has to survive a move goes through the handle's `index`. + +### Per-quantum call order + +`Graph` exposes three calls and the render loop makes them in this order every quantum: + +```cpp +graph.processEvents(); // drain both channels, apply mutations +graph.process(); // toposort if dirty, compact, settle processable state +for (auto &&[node, inputs] : graph.iter()) { node.process(inputs, frames); } +``` + +`processEvents` is the only step that may touch memory that was not there a quantum ago (it adopts pre-grown buffers). Everything after it is +allocation-free. + +```mermaid +flowchart LR + PE["processEvents
drain A, then B"] --> D{"order
dirty?"} + D -- yes --> TS["kahn_toposort"] --> O + D -- no --> O{"any node
orphaned?"} + O -- no --> S + O -- yes --> MD["markDeletions"] --> AS["assign targets
free deleted lists"] --> RM["remapListsToTargetIndex"] --> SH["shift left
resize"] --> S + S["settleProcessableState"] --> IT["iter → process()"] +``` + +The steps between `kahn_toposort` and `settleProcessableState` make up `sortAndCompact()`. In the steady state, no edge changed and nothing is +orphaned, so the quantum takes the two `no` branches and the whole thing is a `bool` test plus one scan of the `orphaned` bits. + +### Topological sort + +The sort runs only when an edge was added or removed since the last one; the edge events mark the order dirty. It is Kahn's algorithm run in +reverse, sinks first, and it uses no extra memory: + +1. Count each node's out-degree by walking every input list and incrementing the *input's* counter (`topo_out_degree`). +2. Push every node with out-degree 0 (the sinks) onto a ready stack. The stack is a linked list threaded through `target_index`, so it costs + nothing to allocate. Any order of taking ready nodes gives a valid topological order, so a stack is enough. +3. Pop until the stack is empty. Each popped node gets the next position counting down from the end (`--write`), written into the same + `target_index` field that held its stack link a moment ago. Every input whose out-degree drops to 0 is pushed. Sinks end up at the back, + sources at the front. +4. Rewrite every index stored in the pool (inputs and links) to the new positions. This has to happen before nodes move. +5. Apply the permutation in place with cycle sort: `while (nodes[i].target_index != i) swap(nodes[i], nodes[target])`. +6. Write the new position into each handle's `index` and reset scratch. + +Cycles cannot reach this code because `HostGraph` rejects them, so every node is eventually popped. + +#### Worked example + +Three nodes inserted in the wrong order for rendering: the destination first, then a gain, then an oscillator, wired `Osc → Gain → Dest`. +Each node's input list holds array indices, not node names: `Dest.inputs = [1]` is one input, the node currently at index 1 (`Gain`), and +`Gain.inputs = [2]` is one input, the node at index 2 (`Osc`). The diagrams below draw the array in index order and write each input as +`index = node` so the two are easy to tell apart. + +```mermaid +flowchart LR + subgraph before["array before the sort, index order"] + direction LR + b0["0: Dest
inputs: [1 = Gain]"] + b1["1: Gain
inputs: [2 = Osc]"] + b2["2: Osc
inputs: []"] + b0 ~~~ b1 ~~~ b2 + end +``` + +Phase 1 counts out-degrees: `Osc 1, Gain 1, Dest 0`. Phase 2 then runs the stack. Each row is the state after the operation in the first column; +`target_index` shows the field's meaning at that moment, a stack link (`→`) or a final position: + +| step | stack (top first) | `write` | Dest | Gain | Osc | +|---|---|---|---|---|---| +| push Dest (out-degree 0) | Dest | 3 | → −1 | −1 | −1 | +| pop Dest, assign `--write` | *empty* | 2 | **2** | −1 | −1 | +| Dest's input Gain reaches 0, push | Gain | 2 | 2 | → −1 | −1 | +| pop Gain, assign | *empty* | 1 | 2 | **1** | −1 | +| Gain's input Osc reaches 0, push | Osc | 1 | 2 | 1 | → −1 | +| pop Osc, assign | *empty* | 0 | 2 | 1 | **0** | + +Phase 3 rewrites the stored indices through `target_index` while the nodes are still in place: `Dest.inputs [1]` stays `[1]` because Gain's +target is 1, and `Gain.inputs [2]` becomes `[0]` because Osc's target is 0. Phase 4 cycle-sorts: at `i = 0`, `Dest` wants position 2, so it +swaps with `Osc`; now every node is at its target. Phase 5 writes `handle->index` and resets the field to −1. + +```mermaid +flowchart LR + subgraph after["array after the sort, index order"] + direction LR + a0["0: Osc
inputs: []"] + a1["1: Gain
inputs: [0 = Osc]"] + a2["2: Dest
inputs: [1 = Gain]"] + a0 ~~~ a1 ~~~ a2 + end +``` + +Every input now sits at a lower index than its consumer. That is the guarantee rendering relies on: walking the array left to right, by the time +a node is processed every one of its inputs has already been processed, so its input buffers hold this quantum's data. Here `Osc` renders first +with nothing to wait for, `Gain` finds Osc's output ready, and `Dest` finds Gain's. + +### Compaction + +Compaction removes nodes the audio thread is done with. A node is removed when it is `orphaned`, has no inputs left, and the node itself +agrees it can be destroyed (a source that is still playing says no). It is two passes over the array: + +1. **Mark, then scrub.** Marking runs left to right in topological order and only reads: a node is marked when it is orphaned, every + input of it is already marked, and it agrees. Because inputs come before consumers, an orphaned chain collapses in one pass: the source + is marked, its consumer sees only marked inputs, and so on. Scrubbing runs afterwards, once every mark is final, and drops every input + and link entry that points at a marked node in one loop; direction no longer matters, so links need no special treatment here. +2. **Assign, remap and shift.** Survivors get consecutive `target_index` values in array order; deleted nodes keep −1 and hand their pool + slots back right away. Every stored index is rewritten through `target_index` (the same helper the sort uses), then survivors are moved left + with a single read cursor and write cursor. Slots past the write cursor have their `handle` set to null, which is the `2 → 1` decrement the + host side is waiting for, and `resize` drops them; on a vector that only shrinks, that does not allocate. + +Rewriting indices before moving nodes is the order that matters. Once a node has moved, the old index stored in someone's input list points at +whatever is now sitting there. + +Compaction runs only when at least one node is `orphaned`. Without one, nothing can be marked, so the whole thing is skipped. + +#### Worked example + +Continuing from the sorted array above. JS has dropped its reference to both `Osc` and `Gain`, so both are `orphaned`; the oscillator has +finished playing, so it reports it can be destroyed. The destination is untouched. + +```mermaid +flowchart LR + subgraph before["before compaction, index order"] + direction LR + b0["0: Osc
orphaned
inputs: []"] + b1["1: Gain
orphaned
inputs: [0 = Osc]"] + b2["2: Dest
inputs: [1 = Gain]"] + b0 ~~~ b1 ~~~ b2 + end + style b0 stroke-dasharray: 5 5 + style b1 stroke-dasharray: 5 5 +``` + +The mark pass walks left to right: + +| node | scrub inputs pointing at marked nodes | orphaned | inputs empty | can be destroyed | marked | +|---|---|---|---|---|---| +| Osc | `[]` → `[]` | yes | yes | yes | **yes** | +| Gain | `[0 = Osc]` → `[]` (Osc is marked) | yes | yes, now | yes | **yes** | +| Dest | `[1 = Gain]` → `[]` (Gain is marked) | no | | | no | + +`Gain` was only removable because `Osc` was handled first, which is why this pass needs the array sorted. Then the survivors get their targets +(`Dest → 0`), the deleted nodes free their (already empty) lists, the remap finds nothing left to rewrite, and the shift moves `Dest` to position +0. Positions 1 and 2 get their handles nulled and the vector is resized to 1. + +```mermaid +flowchart LR + subgraph after["after compaction"] + direction LR + a0["0: Dest
inputs: []"] + end + h1["Osc handle
use_count 2 → 1"] + h2["Gain handle
use_count 2 → 1"] + a0 ~~~ h1 ~~~ h2 +``` + +The two released handles are what `HostGraph::collectDisposedNodes` will find on the JS side. + +### Processable state and links + +Not every node in the array should render. Which ones do is decided once per quantum, after compaction. A node is in one of three states: +*always processable* for the pull roots (the destination, an analyser), *not processable*, or *conditionally processable* for nodes that are +pulled this quantum only. The settle is a reachability walk: starting from every node that is not *not processable* on entry (the roots), it +promotes each dependency from *not* to *conditional* and continues from the nodes it just promoted. A node's dependencies are its audio +inputs plus its **links** (below). The walk is a depth-first traversal that borrows `target_index` as an embedded stack, the same trick the +toposort uses for its ready set, and it uses the state itself as the visited marker: a node is pushed only on its *not* to *conditional* +transition, so every node and every list is visited once and the pass is O(V+E). A conditional node flips itself back to *not* after it +renders, so the cone is re-derived from the roots every quantum with no global reset. + +Because the walk is reachability and not an array scan, it does not care where a dependency sits in the array. That is what lets links share +the loop with inputs. Some nodes have to be rendered together without an audio edge between them; the delay node's reader and writer share a +ring buffer and are the example. A link is a one-way entry in the second per-node list (`link_head`). Links take no part in the toposort and +carry no audio, so a link target may sit at a higher index than the node that pulls it, which an index-ordered scan would have to revisit. + +```mermaid +flowchart LR + subgraph arr["array, pulled from the root"] + direction LR + n0["0: Osc A
NOT → CONDITIONAL"] + n1["1: Osc B
NOT (stays)"] + n2["2: Gain
NOT → CONDITIONAL"] + n3["3: Dest
ALWAYS"] + end + n3 -- "pulls input" --> n2 -- "pulls input" --> n0 + n1 -. "no consumer, not pulled" .-> n1 +``` + +`Osc B` is in the array and sorted correctly, but nothing downstream of it is processable, so it is not rendered this quantum. Its state is not +touched at all; `iter()` simply skips it. + +## Input pool + +Every list an `AudioGraph::Node` owns, inputs and links, is a singly linked list whose nodes are slots in one `InputPool`. A slot is 8 bytes: + +```cpp +struct Slot { + union { + struct { std::uint32_t val; std::uint32_t next; }; // in a list + std::uint32_t next_free; // on the free list + }; +}; +``` + +`val` is the index of the input node, `next` the index of the next slot, `kNull` (`UINT32_MAX`) terminates a list. Unused slots are threaded +into a free list through `next_free`, which overlaps `val`. Adding an input is `push`: pop a slot from the free list, write `val` and `next`, +make it the new head. Removing one is a walk to the slot and a push back onto the free list. Both are O(1) in memory and never call the allocator. + +```mermaid +flowchart LR + subgraph pool["InputPool slots"] + direction LR + s0["0: val=3, next=2"] + s1["1: free"] + s2["2: val=7, next=kNull"] + s3["3: free"] + end + n["Node 9
input_head = 0"] --> s0 --> s2 + fh["free_head = 1"] --> s1 --> s3 +``` + +Indices rather than pointers keep a slot at 8 bytes and, more importantly, keep the lists valid when the whole pool is copied into a bigger +buffer. + +### Growth + +The pool can grow itself, and does so during construction and in tests, but on the audio thread the free list must never run dry. That is +arranged on the JS side: `Graph` remembers the capacity it has ensured so far, and before it sends any event that pushes a slot it checks +whether the host-side edge and link count exceeds half of it. If so it allocates a slot array twice the current need and sends a grow event +ahead of the mutation. On the audio thread the pool copies the old slots with `memcpy` (valid because they hold indices, not pointers), +threads the new slots onto the free list, swaps the buffer in and returns the old one, which the event hands to the disposer. + +The node vector grows the same way: the host node count is compared with the ensured capacity, a vector with the new capacity is reserved on the +JS thread, and the audio thread adopts it by moving every live node across. Both checks run *before* the mutation event is sent, so the FIFO +channel delivers the grow first. + +```mermaid +sequenceDiagram + participant JS as JS thread + participant A as Channel A + participant AU as Audio thread + participant D as Disposer + JS->>JS: addEdge: host graph updated,
edges + links > ensured / 2 + JS->>JS: allocate slots[2 × need] + JS->>A: grow(slots) + JS->>A: push edge + Note over AU: quantum begins + AU->>A: drain + AU->>AU: adoptBuffer: memcpy old slots,
thread new ones onto free list + AU->>D: old slot array + AU->>AU: push edge (free list non-empty) +``` + +## Event channels + +`Graph` owns two SPSC channels into the audio thread, one per producing thread: + +- **Channel A**, JS thread producer: `addNode`, `addEdge`, `removeEdge`, `removeAllEdges`, `linkNodes`, channel renegotiation and grow events. +- **Channel B**, GC finalizer producer: `removeNode` only, because `HostNode` destructors run on the JS GC thread. + +A single-producer channel keeps its order, but two channels have no order between them. That matters for exactly one pair of messages: a +node's removal on B must not be applied before that node's creation, or a connection to it, that is still queued on A. So every removal sent +on B remembers how much of channel A had been written at that moment, and the audio thread does not apply it until it has drained A at least +that far. `processEvents` is therefore: drain A, then apply whatever on B is now allowed. A removal that is not yet allowed, because the JS +thread is mid-send, waits for the next quantum. + +```mermaid +sequenceDiagram + participant JS as JS thread + participant A as Channel A + participant GC as GC thread + participant B as Channel B + participant AU as Audio thread + JS->>A: addNode(X) + JS->>A: addEdge(X → Y) + GC->>B: removeNode(X), after everything on A so far + Note over AU: quantum begins + AU->>A: drain: addNode(X), addEdge(X → Y) + AU->>B: removeNode(X) is now allowed, apply it +``` + +"So far" is enough because the GC thread flips the host-side `ghost` flag before it sends, and from that moment `addEdge` / `removeEdge` +refuse the node, so nothing that references it can be sent on A afterwards. The rule turns "sent before" on the producers' side into "applied +before" on the audio side without a lock between the two producers. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioNodeHostObject.h index 567430c2c..2df5b17e7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioNodeHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioNodeHostObject.h @@ -43,8 +43,8 @@ class AudioNodeHostObject : public HostObject, JSI_HOST_FUNCTION_DECL(connect); JSI_HOST_FUNCTION_DECL(disconnect); - [[nodiscard]] virtual size_t getMemoryPressure() const { - return 300'000; // magic number so node can be destroyed quite fast + [[nodiscard]] virtual constexpr size_t getMemoryPressure() const { + return RENDER_QUANTUM_SIZE * 2 * sizeof(float); // rough estimate of processing buffer size } protected: 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 92857e93f..10e83c06e 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,5 +1,6 @@ #include #include +#include #include namespace audioapi::utils::graph { @@ -45,67 +46,70 @@ void AudioGraph::addNode(std::shared_ptr handle) { nodes.emplace_back(std::move(handle)); } -void AudioGraph::process() { - if (topo_order_dirty) { - topo_order_dirty = false; - kahn_toposort(); - if (topo_order_dirty) { - return; - } - } - - const auto n = static_cast(nodes.size()); +void AudioGraph::markDeletions() { + auto flagged = [this](std::uint32_t idx) { + return nodes[idx].will_be_deleted; + }; - // ── Pass 1: mark deletions (cascading, left-to-right in topo order) ──── - // A node is deleted when: orphaned && no live inputs && canBeDestructed(). - // Because the array is topologically sorted, removing a source first lets - // its dependents see the updated input set and potentially cascade. + // Decide first. A node goes when it is orphaned, every input is going too, + // and it agrees. for (auto &node : nodes) { - pool_.removeIf( - node.input_head, [this](std::uint32_t inp) { return nodes[inp].will_be_deleted; }); - - if (node.orphaned && InputPool::isEmpty(node.input_head) && - node.handle->audioNode->canBeDestructed()) { - node.will_be_deleted = true; - } + node.will_be_deleted = node.orphaned && + std::ranges::all_of(pool_.view(node.input_head), flagged) && + node.handle->audioNode->canBeDestructed(); } - // Processable links may point to higher-index nodes, so their targets' - // will_be_deleted flags are only reliable once the cascade above has fully - // settled. Prune links to deleted nodes in a separate pass. + // Then scrub. Every flag is final now. for (auto &node : nodes) { if (node.will_be_deleted) { continue; } - pool_.removeIf( - node.link_head, [this](std::uint32_t lnk) { return nodes[lnk].will_be_deleted; }); + forEachDependencyList(node, [&](std::uint32_t &head) { pool_.removeIf(head, flagged); }); } +} - // ── Compute new-position remap (stored in after_compaction_ind) ───────── - std::uint32_t new_pos = 0; - for (std::uint32_t i = 0; i < n; i++) { - if (!nodes[i].will_be_deleted) { - nodes[i].after_compaction_ind = static_cast(new_pos); - new_pos++; - } - // deleted nodes keep after_compaction_ind == -1 (default) +void AudioGraph::remapListsToTargetIndex() { + // Must run BEFORE nodes move: once they do, an index stored in a list no + // longer names the node it was written for. + for (auto &node : nodes) { + forEachDependencyList(node, [this](std::uint32_t head) { + for (auto &dep : pool_.mutableView(head)) { + dep = static_cast(nodes[dep].target_index); + } + }); } +} - // ── Pass 2a: remap inputs to post-compaction indices ───────────────────── - // Must happen BEFORE shifting nodes, because shifting invalidates source - // positions that later nodes' inputs may still reference. - for (std::uint32_t e = 0; e < n; e++) { - if (nodes[e].will_be_deleted) { - continue; - } - for (auto &inp : pool_.mutableView(nodes[e].input_head)) { - inp = static_cast(nodes[inp].after_compaction_ind); - } - for (auto &lnk : pool_.mutableView(nodes[e].link_head)) { - lnk = static_cast(nodes[lnk].after_compaction_ind); +void AudioGraph::sortAndCompact() { + if (topo_order_dirty) { + topo_order_dirty = false; + kahn_toposort(); + } + + // Only orphaned nodes can be deleted, so with none present the passes below + // would walk every input list and change nothing. + if (std::ranges::none_of(nodes, [](const Node &node) { return node.orphaned; })) { + return; + } + + const auto n = static_cast(nodes.size()); + + markDeletions(); + + // ── Assign each survivor its post-compaction position ─────────────────── + // Deleted nodes keep target_index == -1 and give their pool slots back now, + // so the remap below never has to special-case them. + std::uint32_t new_pos = 0; + for (auto &node : nodes) { + if (node.will_be_deleted) { + forEachDependencyList(node, [this](std::uint32_t &head) { pool_.freeAll(head); }); + } else { + node.target_index = static_cast(new_pos++); } } + remapListsToTargetIndex(); + // ── Pass 2b: compact — shift kept nodes left ─────────────────────────── std::uint32_t b = 0; for (std::uint32_t e = 0; e < n; e++) { @@ -114,8 +118,6 @@ void AudioGraph::process() { } if (b != e) { nodes[b] = std::move(nodes[e]); - nodes[e].input_head = InputPool::kNull; // prevent double-free in truncation - nodes[e].link_head = InputPool::kNull; // prevent double-free in truncation } nodes[b].handle->index = b; b++; @@ -123,18 +125,15 @@ void AudioGraph::process() { // Truncate — dropping shared_ptr decrements refcount (2 → 1); // HostGraph detects this and destroys the ghost on the main thread. + // Handles may have been moved-from during compaction, so just null them. for (std::uint32_t i = b; i < n; i++) { - // Free any lingering pool slots (should already be empty for deleted nodes) - pool_.freeAll(nodes[i].input_head); - pool_.freeAll(nodes[i].link_head); - // Handle may have been moved-from during compaction, so just null it nodes[i].handle = nullptr; } nodes.resize(b); // Reset scratch fields for next compaction for (auto &node : nodes) { - node.after_compaction_ind = -1; + node.target_index = -1; node.will_be_deleted = false; } } @@ -142,45 +141,36 @@ void AudioGraph::process() { void AudioGraph::settleProcessableState() { using PS = GraphObject::PROCESSABLE_STATE; - const auto n = static_cast(nodes.size()); - if (n == 0) { - return; - } + std::int32_t top = -1; + auto push = [&](std::uint32_t i) { + nodes[i].target_index = top; + top = static_cast(i); + }; - // Promote a single node to CONDITIONAL_PROCESSABLE. Never overwrites an - // ALWAYS_PROCESSABLE seed and never re-activates a node that opted out via - // excludeFromProcessablePull_. Returns true only on a NOT -> CONDITIONAL - // transition, so callers can detect real progress. - auto pull = [this](std::uint32_t idx) -> bool { - auto &obj = nodes[idx].handle->audioNode; - if (obj->processableState_ == PS::NOT_PROCESSABLE && !obj->excludeFromProcessablePull_) { - obj->processableState_ = PS::CONDITIONAL_PROCESSABLE; - return true; + for (std::uint32_t i = 0; i < nodes.size(); i++) { + if (nodes[i].handle->audioNode->processableState_ != PS::NOT_PROCESSABLE) { + push(i); } - return false; - }; + } - // Inputs always sit at a lower index than their consumer, but link nodes - // can target a higher index. If we switch nodes in higher hierarchy first, - // we may miss some nodes in lower hierarchy that are now processable. - // We need to iterate again to ensure we process all nodes in the graph. - bool changed = true; - while (changed) { - changed = false; - for (std::uint32_t i = n; i-- > 0;) { - const PS state = nodes[i].handle->audioNode->processableState_; - if (state == PS::NOT_PROCESSABLE) { - continue; - } - for (std::uint32_t inp : pool_.view(nodes[i].input_head)) { - pull(inp); - } - for (std::uint32_t lnk : pool_.view(nodes[i].link_head)) { - if (pull(lnk)) { - changed = true; + // Promote each popped node's dependencies to CONDITIONAL_PROCESSABLE and + // push the ones that transitioned. A node that opted out via + // excludeFromProcessablePull_ stays NOT_PROCESSABLE and is never pushed, so + // nothing propagates through it. + while (top != -1) { + const auto idx = static_cast(top); + top = nodes[idx].target_index; + nodes[idx].target_index = -1; + + forEachDependencyList(nodes[idx], [&](std::uint32_t head) { + for (auto dep : pool_.view(head)) { + auto &obj = *nodes[dep].handle->audioNode; + if (obj.processableState_ == PS::NOT_PROCESSABLE && !obj.excludeFromProcessablePull_) { + obj.processableState_ = PS::CONDITIONAL_PROCESSABLE; + push(dep); } } - } + }); } } @@ -194,57 +184,44 @@ void AudioGraph::kahn_toposort() { // Phase 1: compute out-degree for (const auto &nd : nodes) { - for (std::uint32_t inp : pool_.view(nd.input_head)) { + for (auto inp : pool_.view(nd.input_head)) { nodes[inp].topo_out_degree++; } } - // Phase 2: reverse Kahn BFS — sinks first, sources last in dequeue order. - // FIFO queue embedded as a linked list through after_compaction_ind. - std::int32_t qh = -1, qt = -1; - auto enq = [&](std::uint32_t i) { - nodes[i].after_compaction_ind = -1; - if (qh == -1) [[unlikely]] { - qh = qt = static_cast(i); - } else { - nodes[qt].after_compaction_ind = static_cast(i); - qt = static_cast(i); - } + // Phase 2: reverse Kahn — sinks first, sources last in pop order. + std::int32_t top = -1; + auto push = [&](std::uint32_t i) { + nodes[i].target_index = top; // temporary: link to the node below on the ready stack + top = static_cast(i); }; for (std::uint32_t i = 0; i < n; i++) { if (nodes[i].topo_out_degree == 0) { - enq(i); + push(i); } } std::uint32_t write = n; - while (qh != -1) { - auto idx = static_cast(qh); - qh = nodes[idx].after_compaction_ind; - nodes[idx].after_compaction_ind = static_cast(--write); + while (top != -1) { + const auto idx = static_cast(top); + top = nodes[idx].target_index; + nodes[idx].target_index = static_cast(--write); // final: position after the sort - for (std::uint32_t inp : pool_.view(nodes[idx].input_head)) { + for (auto inp : pool_.view(nodes[idx].input_head)) { if (--nodes[inp].topo_out_degree == 0) { - enq(inp); + push(inp); } } } // Phase 3: remap input (and link) indices to new positions (before nodes move) - for (auto &nd : nodes) { - for (std::uint32_t &inp : pool_.mutableView(nd.input_head)) { - inp = static_cast(nodes[inp].after_compaction_ind); - } - for (std::uint32_t &lnk : pool_.mutableView(nd.link_head)) { - lnk = static_cast(nodes[lnk].after_compaction_ind); - } - } + remapListsToTargetIndex(); // Phase 4: apply permutation in place via cycle sort for (std::uint32_t i = 0; i < n; i++) { - while (nodes[i].after_compaction_ind != static_cast(i)) { - auto t = static_cast(nodes[i].after_compaction_ind); + while (nodes[i].target_index != static_cast(i)) { + const auto t = static_cast(nodes[i].target_index); std::swap(nodes[i], nodes[t]); } } @@ -254,7 +231,7 @@ void AudioGraph::kahn_toposort() { if (nodes[i].handle) { nodes[i].handle->index = i; } - nodes[i].after_compaction_ind = -1; + nodes[i].target_index = -1; } } 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 06ad9b658..725409e71 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 @@ -38,8 +38,7 @@ class AudioGraph { std::uint32_t topo_out_degree : 31 = 0; // scratch — Kahn's out-degree counter unsigned will_be_deleted : 1 = 0; // scratch — marked for compaction removal - std::int32_t after_compaction_ind : 31 = - -1; // scratch — new index after compaction / BFS linked-list next + std::int32_t target_index : 31 = -1; // scratch - new index after compaction /// Node is removed when: orphaned && inputs.empty() && canBeDestructed() unsigned orphaned : 1 = 0; // means this node was removed from host graph @@ -137,8 +136,8 @@ class AudioGraph { // ── Mutators ──────────────────────────────────────────────────────────── - /// @brief Marks the topological ordering as dirty so the next process() - /// recomputes it. + /// @brief Marks the topological ordering as dirty so the next + /// sortAndCompact() recomputes it. void markDirty(); /// @brief Adds a new node. AudioGraph takes shared ownership of the handle. @@ -146,7 +145,8 @@ class AudioGraph { void addNode(std::shared_ptr handle); /// @brief Recomputes topological order (if dirty), then compacts the graph - /// by removing orphaned, input-free, destructible nodes. + /// by removing orphaned, input-free, destructible nodes. Compaction is + /// skipped entirely when no node is orphaned. /// /// When a node is compacted out its `shared_ptr` is released /// (refcount drops 2 → 1). HostGraph detects this via `use_count() == 1` @@ -159,41 +159,63 @@ class AudioGraph { /// Time: O(V + E) /// /// Extra space: O(1) — everything in place. - void process(); + void sortAndCompact(); /// @brief Recomputes every node's processable state for the coming render - /// quantum via a reverse-topological pull. - /// - /// The graph is kept topologically sorted (sources first, sinks last), so - /// a right-to-left walk visits every consumer before its producers. Seed - /// nodes (AudioDestinationNode, AnalyserNode, ...) are ALWAYS_PROCESSABLE - /// and act as pull roots. - /// - /// Because links are not part of the topological order, a marked link - /// target may sit *after* the node that pulled it; the pull therefore - /// iterates to a fixpoint. State only ever transitions NOT -> CONDITIONAL, - /// so the loop is monotonic and terminates. Link-free graphs settle in a - /// single pass. + /// quantum. + /// + /// A node renders this quantum when it is reachable from a seed by walking + /// dependencies (audio inputs and processable links) backwards. Seeds are + /// the nodes whose state is not NOT_PROCESSABLE on entry: the + /// ALWAYS_PROCESSABLE pull roots (AudioDestinationNode, AnalyserNode, ...). + /// The walk is a depth-first traversal seeded from those roots, with + /// `processableState_` doubling as the visited marker: a node is pushed + /// only on its NOT -> CONDITIONAL transition, so every node and every + /// dependency list is visited at most once. The traversal does not depend + /// on the topological order, which is what lets links (whose targets may + /// sit anywhere in the array) share the loop with inputs. + /// + /// Uses `target_index` as an embedded stack, the same way kahn_toposort() + /// does; it is restored to -1 for every node before returning. /// /// Must derive state ONLY from `processableState_`, never from /// `AudioNode::isProcessable()` — a tail-bearing node keeps the latter true /// after a disconnect and would otherwise re-activate its whole upstream /// cone. /// - /// Allocation-free. Call after process() (indices and - /// topological order must be settled) and before the forward iter() pass. + /// Allocation-free. Call after sortAndCompact() (indices must be settled) + /// and before the forward iter() pass. /// @note Audio Thread only void settleProcessableState(); private: std::vector nodes; // always kept topologically sorted InputPool pool_; // pool backing all input linked lists - bool topo_order_dirty = false; // set by markDirty(), cleared by process() + bool topo_order_dirty = false; // set by markDirty(), cleared by sortAndCompact() + + /// @brief Flags nodes for compaction (`will_be_deleted`), then scrubs every + /// dependency list entry that points at a flagged node. Flagging cascades in + /// one left-to-right pass because the array is topologically sorted. + void markDeletions(); + + /// @brief Rewrites every index stored in the input and link lists through + /// `target_index`. Call after targets are assigned and before nodes move. + void remapListsToTargetIndex(); + + /// @brief Invokes `fn(head)` for each list head that holds dependencies of + /// `node`: the audio inputs and the processable links. Dependencies are + /// what a processable node pulls into processing; only the input list + /// additionally carries audio and orders the toposort. + template + static void forEachDependencyList(Node &node, Fn fn) { + fn(node.input_head); + fn(node.link_head); + } /// @brief In-place Kahn's toposort (sources first, sinks last). /// - /// Uses `after_compaction_ind` as an embedded FIFO linked-list for the - /// BFS queue, and cycle-sort for the final permutation. + /// Uses `target_index` as an embedded linked-list stack for the + /// ready set, and cycle-sort for the final permutation. /// /// Time: O(V + E) /// diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.cpp index c62a73a7b..05223611b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.cpp @@ -71,7 +71,7 @@ void Graph::processEvents() { } void Graph::process() { - audioGraph.process(); + audioGraph.sortAndCompact(); audioGraph.settleProcessableState(); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h index 5e6726d39..71e3c4898 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/Graph.h @@ -101,7 +101,8 @@ class Graph { void processEvents(); /// @brief Runs toposort + compaction on the audio graph, then settles every - /// node's processable state for the coming quantum (reverse-topo pull). + /// node's processable state for the coming quantum (dependency pull from + /// the always-processable roots). /// Allocation-free. /// @note Should be called only from the audio thread. void process(); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/GraphObject.h b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/GraphObject.h index 7b943af50..ce25e2e9d 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/GraphObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/utils/graph/GraphObject.h @@ -116,7 +116,7 @@ class GraphObject { PROCESSABLE_STATE processableState_ = PROCESSABLE_STATE::NOT_PROCESSABLE; /// @brief When set, AudioGraph::settleProcessableState() will never promote - /// this node back to CONDITIONAL_PROCESSABLE during the reverse-topo pull. + /// this node back to CONDITIONAL_PROCESSABLE during the dependency pull. /// /// Used to make `disable()` sticky: a source that finished playback while /// still connected to a processable downstream must stay idle for good, 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 39cce23c5..c8ecf8b43 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 @@ -261,9 +261,6 @@ HostGraph::HostGraph() = default; HostGraph::~HostGraph() { std::scoped_lock lock(nodesMutex_); - for (Node *n : nodes) { - n->linkedNodes.clear(); - } for (Node *n : nodes) { delete n; } @@ -285,9 +282,6 @@ HostGraph::HostGraph(HostGraph &&other) noexcept auto HostGraph::operator=(HostGraph &&other) noexcept -> HostGraph & { if (this != &other) { std::scoped_lock lock(nodesMutex_, other.nodesMutex_); - for (Node *n : nodes) { - n->linkedNodes.clear(); - } for (Node *n : nodes) { delete n; } diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp index 31ee82fb8..e36b7d8d2 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphFuzzTest.cpp @@ -251,7 +251,7 @@ TEST_P(AudioGraphFuzzTest, RandomOps) { for (size_t i = 0; i < initialCount; i++) { doAddNode(); } - graph.process(); + graph.sortAndCompact(); assertAllInvariants("after initial seeding"); for (size_t i = 0; i < opCount; i++) { @@ -307,7 +307,7 @@ TEST_P(AudioGraphFuzzTest, RandomOps) { } else { // Process bool hadDupsBefore = checkDuplicateInputs("BEFORE process at op " + std::to_string(i)); - graph.process(); + graph.sortAndCompact(); // Null out handles for nodes that were compacted away. for (auto &h : handles) { @@ -333,7 +333,7 @@ TEST_P(AudioGraphFuzzTest, RandomOps) { } // Final process - graph.process(); + graph.sortAndCompact(); for (auto &h : handles) { if (h && (h->index >= graph.size() || graph[h->index].handle != h)) { h = nullptr; diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp index bf9ca3030..ae703d0a2 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/AudioGraphTest.cpp @@ -97,7 +97,7 @@ TEST_F(AudioGraphTest, TopoSort_LinearChain) { graph.pool().push(graph[h[2]->index].input_head, h[1]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_LT(posOf(0), posOf(1)); EXPECT_LT(posOf(1), posOf(2)); @@ -111,7 +111,7 @@ TEST_F(AudioGraphTest, TopoSort_ReversedInsertion) { graph.pool().push(graph[h[1]->index].input_head, h[0]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_LT(posOf(0), posOf(1)); EXPECT_LT(posOf(1), posOf(2)); @@ -134,7 +134,7 @@ TEST_F(AudioGraphTest, TopoSort_Diamond) { graph.pool().push(graph[h[3]->index].input_head, h[2]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_LT(posOf(0), posOf(1)); EXPECT_LT(posOf(0), posOf(2)); @@ -154,7 +154,7 @@ TEST_F(AudioGraphTest, TopoSort_FanIn) { graph.pool().push(graph[h[3]->index].input_head, h[2]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_LT(posOf(0), posOf(3)); EXPECT_LT(posOf(1), posOf(3)); @@ -172,7 +172,7 @@ TEST_F(AudioGraphTest, TopoSort_DisconnectedComponents) { graph.pool().push(graph[h[3]->index].input_head, h[2]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_LT(posOf(0), posOf(1)); EXPECT_LT(posOf(2), posOf(3)); @@ -186,7 +186,7 @@ TEST_F(AudioGraphTest, TopoSort_SingleNode) { auto h = addNodes(1); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 1u); EXPECT_EQ(posOf(0), 0); @@ -202,12 +202,12 @@ TEST_F(AudioGraphTest, TopoSort_SkippedWhenNotDirty) { graph.pool().push(graph[h[2]->index].input_head, h[1]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); auto orderAfterFirst = getOrder(); // process again without marking dirty — order should stay identical - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(getOrder(), orderAfterFirst); } @@ -227,7 +227,7 @@ TEST_F(AudioGraphTest, Compact_RemovesOrphanedDestructibleLeaf) { graph.pool().freeAll(graph[h[2]->index].input_head); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 2u); // Node 2 should be gone @@ -244,7 +244,7 @@ TEST_F(AudioGraphTest, Compact_KeepsOrphanedNodeWithInputs) { graph[h[1]->index].orphaned = true; graph.markDirty(); - graph.process(); + graph.sortAndCompact(); // Node 1 is orphaned but still has inputs — should stay EXPECT_EQ(graph.size(), 2u); @@ -267,7 +267,7 @@ TEST_F(AudioGraphTest, Compact_KeepsNonDestructible) { // h1 has no inputs, is orphaned, but canBeDestructed() returns false graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 2u); // still 2 — node 1 stays } @@ -288,7 +288,7 @@ TEST_F(AudioGraphTest, Compact_RemovesOnceDestructible) { graph[h1->index].orphaned = true; graph.markDirty(); - graph.process(); // first pass: node 1 stays (not destructible) + graph.sortAndCompact(); // first pass: node 1 stays (not destructible) EXPECT_EQ(graph.size(), 2u); // Now make it destructible @@ -296,7 +296,7 @@ TEST_F(AudioGraphTest, Compact_RemovesOnceDestructible) { ASSERT_NE(mockNode, nullptr); mockNode->setDestructible(true); - graph.process(); // second pass: node 1 should be removed + graph.sortAndCompact(); // second pass: node 1 should be removed EXPECT_EQ(graph.size(), 1u); EXPECT_EQ(posOf(1), -1); } @@ -313,7 +313,7 @@ TEST_F(AudioGraphTest, Compact_UpdatesHandleIndices) { graph.pool().push(graph[h[3]->index].input_head, h[2]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); // Now orphan node 1 (remove its inputs so it can be deleted) graph[h[1]->index].orphaned = true; @@ -322,7 +322,7 @@ TEST_F(AudioGraphTest, Compact_UpdatesHandleIndices) { // Also remove node 1 from node 2's inputs (otherwise it references a deleted node) graph.pool().remove(graph[h[2]->index].input_head, h[1]->index); - graph.process(); + graph.sortAndCompact(); // After compaction: 3 nodes remain (0, 2, 3) EXPECT_EQ(graph.size(), 3u); @@ -344,7 +344,7 @@ TEST_F(AudioGraphTest, Compact_MultipleOrphans) { graph[h[3]->index].orphaned = true; graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 3u); EXPECT_NE(posOf(0), -1); @@ -373,7 +373,7 @@ TEST_F(AudioGraphTest, Compact_CascadingRemoval) { graph[h[0]->index].orphaned = true; graph.markDirty(); - graph.process(); + graph.sortAndCompact(); // Node 0 should be removed (orphaned, no inputs, destructible) // Node 1 still references old index of 0 in its inputs — that input will be cleaned on next process() @@ -391,7 +391,7 @@ TEST_F(AudioGraphTest, Compact_RemoveAllNodes) { graph[h[2]->index].orphaned = true; graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 0u); EXPECT_TRUE(graph.empty()); @@ -402,7 +402,7 @@ TEST_F(AudioGraphTest, Compact_RemoveAllNodes) { // ===================================================================== TEST_F(AudioGraphTest, Process_EmptyGraph) { - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 0u); } @@ -417,7 +417,7 @@ TEST_F(AudioGraphTest, MarkDirty_Idempotent) { graph.markDirty(); graph.markDirty(); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 2u); EXPECT_LT(posOf(0), posOf(1)); @@ -446,7 +446,7 @@ TEST_F(AudioGraphTest, TopoSort_ComplexDAG) { graph.pool().push(graph[h[5]->index].input_head, h[4]->index); graph.markDirty(); - graph.process(); + graph.sortAndCompact(); // Check all dependency constraints EXPECT_LT(posOf(0), posOf(2)); @@ -472,7 +472,7 @@ TEST_F(AudioGraphTest, Process_AddAndRemoveInterleaved) { graph[h1->index].test_node_identifier__ = 1; graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 2u); // Orphan 0, add 2 @@ -482,7 +482,7 @@ TEST_F(AudioGraphTest, Process_AddAndRemoveInterleaved) { graph[h2->index].test_node_identifier__ = 2; graph.markDirty(); - graph.process(); + graph.sortAndCompact(); EXPECT_EQ(graph.size(), 2u); EXPECT_EQ(posOf(0), -1); diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/BridgeNodeTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/BridgeNodeTest.cpp index 64d6796d9..eba64ff24 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/BridgeNodeTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/BridgeNodeTest.cpp @@ -120,7 +120,7 @@ TEST_F(BridgeGraphTest, BridgeCreatesThreeNodePath) { EXPECT_EQ(audioGraph.size(), 3u); // Topo sort should place them: source, bridge, owner - audioGraph.process(); + audioGraph.sortAndCompact(); // Verify source comes before bridge comes before owner auto srcIdx = source->handle->index; @@ -197,7 +197,7 @@ TEST_F(BridgeIterTest, IterSkipsNonProcessableNodes) { ASSERT_TRUE(addEdge(processable1, nonProcessable)); ASSERT_TRUE(addEdge(nonProcessable, processable2)); - audioGraph.process(); + audioGraph.sortAndCompact(); audioGraph.settleProcessableState(); // iter() should only yield 2 nodes (skip the non-processable one) @@ -219,7 +219,7 @@ TEST_F(BridgeIterTest, AllProcessableNodesInTopoOrder) { ASSERT_TRUE(addEdge(a, bridge)); ASSERT_TRUE(addEdge(bridge, b)); ASSERT_TRUE(addEdge(b, c)); - audioGraph.process(); + audioGraph.sortAndCompact(); audioGraph.settleProcessableState(); // Should yield A, bridge, B, C in topo order (bridge is now processable) @@ -241,7 +241,7 @@ TEST_F(BridgeIterTest, InputsViewMayReferenceBridgeIndices) { ASSERT_TRUE(addEdge(source, bridge)); ASSERT_TRUE(addEdge(bridge, owner)); - audioGraph.process(); + audioGraph.sortAndCompact(); audioGraph.settleProcessableState(); size_t processableCount = 0; @@ -265,7 +265,7 @@ TEST_F(BridgeGraphTest, OrphanedBridgeWithNoInputsRemoved) { // Mark orphaned removeNode(bridge); - audioGraph.process(); + audioGraph.sortAndCompact(); EXPECT_EQ(audioGraph.size(), 0u); } @@ -277,12 +277,12 @@ TEST_F(BridgeGraphTest, SourceRemovalCascadesBridgeRemoval) { ASSERT_TRUE(addEdge(source, bridge)); ASSERT_TRUE(addEdge(bridge, owner)); - audioGraph.process(); + audioGraph.sortAndCompact(); EXPECT_EQ(audioGraph.size(), 3u); // Remove source — bridge loses its only input removeNode(source); - audioGraph.process(); + audioGraph.sortAndCompact(); // Source compacted (orphaned, no inputs, destructible) // Bridge compacted (orphaned via edge removal cascade — its input was removed) @@ -304,14 +304,14 @@ TEST_F(BridgeGraphTest, BridgeOrphanedAndNoInputsGetsCompacted) { ASSERT_TRUE(addEdge(source, bridge)); ASSERT_TRUE(addEdge(bridge, owner)); - audioGraph.process(); + audioGraph.sortAndCompact(); EXPECT_EQ(audioGraph.size(), 3u); // Orphan source and bridge removeNode(source); removeEdge(bridge, owner); removeNode(bridge); - audioGraph.process(); + audioGraph.sortAndCompact(); // Both source and bridge should be compacted EXPECT_EQ(audioGraph.size(), 1u); // only owner remains diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp index 2df07f1e3..49e939cc5 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/GraphCycleDebugTest.cpp @@ -79,7 +79,7 @@ class GraphCycleDebugTest : public ::testing::TestWithParam { } void doProcess() { - audioGraph.process(); + audioGraph.sortAndCompact(); // Note: this test only triggers audioGraph processing here. // Disposed-node collection is handled by higher-level wrappers in production code, // not directly inside the HostGraph addEdge/removeEdge methods used in this test. diff --git a/packages/react-native-audio-api/common/cpp/test/src/graph/SettleProcessableTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/graph/SettleProcessableTest.cpp index 64d6c6d45..f3894f3a2 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/graph/SettleProcessableTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/graph/SettleProcessableTest.cpp @@ -66,7 +66,7 @@ class SettleProcessableTest : public ::testing::Test { } void settleOnly() { - audioGraph.process(); + audioGraph.sortAndCompact(); audioGraph.settleProcessableState(); }