audio-graph-bsd
Real-time-safe directed node-graph audio processing engine — schedules
[AudioNode][audio-core-bsd]s in topological order, pre-allocates every scratch
buffer at compile time, and runs an allocation-free
process_cycle on the RT thread.
Note: The doc comments on each public item are the primary reference. This README is an overview only.
Role
This crate is a standalone graph engine that depends on
audio-core-bsd for the node contract
(AudioNode, AudioFrame, ProcessContext, PortDescriptor). It provides:
- A directed acyclic graph of
AudioNodes wired output-port → input-port. - Topological scheduling at compile time, with cycle rejection.
- Pre-allocated RT scratch so the per-cycle driver never allocates, locks, panics, or performs a system call.
- rtrb-backed bridge nodes for shuttling audio to/from worker threads.
- A trait-based Rust public API surface (not a C FFI).
Core API
| Item | Description |
|---|---|
[Graph] |
The engine: owns nodes, edges, and the pre-allocated per-port scratch frames. |
[GraphConfig] |
Compile-time config fixing num_frames / sample_rate / channels for every scratch buffer. |
[NodeId] / [PortIdx] / [LinkId] |
Stable usize identifiers for nodes, ports, and links. |
[GraphError] |
Construction/compile error enum (thiserror-backed): cycle, port, direction, compatibility, state. |
[RingSource] |
Source node draining a worker thread via a wait-free rtrb consumer. |
[RingSink] |
Sink node tapping graph output for a worker thread consumer. |
The graph moves through three phases:
- Build — [
Graph::add_node] registers nodes and [Graph::link] wires output ports to input ports (with direction / channel / format validation). - Compile — [
Graph::compile] runs the topological sort, rejects cycles, and pre-allocates every per-port scratch frame. Allocation is only permitted here. - Run — [
Graph::process_cycle] drives one audio cycle on the RT thread. [Graph::feed] seeds external inputs before a cycle; [Graph::read_output] / [Graph::read_input] tap results after.
Dependencies
| Dependency | Version | Purpose |
|---|---|---|
audio-core-bsd |
0.1.0 | AudioNode / AudioFrame / ProcessContext / PortDescriptor contract. |
rtrb |
0.3 | Lock-free ring buffer backing RingSource / RingSink. |
thiserror |
2.0 | GraphError derive. |
proptest is a dev-only dependency (property tests) and is not shipped with
the crate. serde / tracing are not currently included.
Status
0.x — experimental. The API is not yet frozen. Breaking changes are expected before a 1.0 release.
- edition: 2021
- MSRV: 1.85
- license: BSD-2-Clause
Example
A minimal two-node graph (source → gain) driven for one cycle. The source is a
no-op node whose output scratch is seeded via [Graph::feed]; the gain node
scales it by 0.5:
use ;
use ;
// A source node: one mono output, no-op `process` (its output scratch is seeded
// externally via `Graph::feed` and left untouched each cycle).
// A gain node: one mono input, one mono output, scales by `gain`.
// 1. Build: add a source and a gain node, wire src:0 -> gain:0.
let mut g = new;
let src = g.add_node;
let dst = g.add_node;
g.link.unwrap;
// 2. Compile: topological sort + pre-allocate every scratch frame.
g.compile.unwrap;
// 3. Run: seed the source, drive one cycle, read the scaled output.
g.feed;
let mut ctx = new;
g.process_cycle.unwrap;
assert!;
A larger, runnable version of this pattern — streaming a 440 Hz sine through
multiple cycles — lives at
examples/simple_route.rs:
Real-time safety boundary
[Graph::process_cycle] is the RT (real-time) entry point. It must not
allocate, acquire locks, panic, or perform any system call, or it will cause
audio dropouts (xruns), priority-inversion stalls, or undefined behaviour.
This is achieved structurally by separating the three phases:
- The topological sort runs in [
Graph::compile] — never inprocess_cycle. - All per-port scratch frames are pre-sized in
compile(), soprocess_cyclenever grows aVec. process_cycleuses only boundedforloops and slice copies over pre-sized buffers; unconnected inputs are zeroed in place.
Conformance is verified at test time with a counting allocator that fails on
any allocation observed inside process_cycle across 1000 cycles (see the RT
alloc-free integration test).
License
BSD-2-Clause. See LICENSE.