Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/skills/audio-nodes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ std::shared_ptr<AudioBuffer> 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_`).

Expand All @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/thread-safety-itc/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
441 changes: 441 additions & 0 deletions packages/internaldocs/docs/graph/graph-implementation.mdx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include <audioapi/core/AudioNode.h>
#include <audioapi/core/utils/graph/AudioGraph.h>
#include <algorithm>
#include <utility>

namespace audioapi::utils::graph {
Expand Down Expand Up @@ -45,67 +46,70 @@ void AudioGraph::addNode(std::shared_ptr<NodeHandle> 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<std::uint32_t>(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<std::int32_t>(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<std::uint32_t>(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<std::uint32_t>(nodes[inp].after_compaction_ind);
}
for (auto &lnk : pool_.mutableView(nodes[e].link_head)) {
lnk = static_cast<std::uint32_t>(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<std::uint32_t>(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<std::int32_t>(new_pos++);
}
}

remapListsToTargetIndex();

// ── Pass 2b: compact — shift kept nodes left ───────────────────────────
std::uint32_t b = 0;
for (std::uint32_t e = 0; e < n; e++) {
Expand All @@ -114,73 +118,59 @@ 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++;
}

// 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;
}
}

void AudioGraph::settleProcessableState() {
using PS = GraphObject::PROCESSABLE_STATE;

const auto n = static_cast<std::uint32_t>(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<std::int32_t>(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<std::uint32_t>(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);
}
}
}
});
}
}

Expand All @@ -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<std::int32_t>(i);
} else {
nodes[qt].after_compaction_ind = static_cast<std::int32_t>(i);
qt = static_cast<std::int32_t>(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<std::int32_t>(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<std::uint32_t>(qh);
qh = nodes[idx].after_compaction_ind;
nodes[idx].after_compaction_ind = static_cast<std::int32_t>(--write);
while (top != -1) {
const auto idx = static_cast<std::uint32_t>(top);
top = nodes[idx].target_index;
nodes[idx].target_index = static_cast<std::int32_t>(--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<std::uint32_t>(nodes[inp].after_compaction_ind);
}
for (std::uint32_t &lnk : pool_.mutableView(nd.link_head)) {
lnk = static_cast<std::uint32_t>(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<std::int32_t>(i)) {
auto t = static_cast<std::uint32_t>(nodes[i].after_compaction_ind);
while (nodes[i].target_index != static_cast<std::int32_t>(i)) {
const auto t = static_cast<std::uint32_t>(nodes[i].target_index);
std::swap(nodes[i], nodes[t]);
}
}
Expand All @@ -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;
}
}

Expand Down
Loading
Loading