Expand description
Real-time-safe directed node-graph audio processing engine for Rust.
audio-graph schedules and drives a directed acyclic graph (DAG) of
AudioNodes on a dedicated real-time (RT) audio
thread. The engine performs topological sorting at compile time, pre-allocates
every scratch buffer, and then runs Graph::process_cycle over the
pre-scheduled order without ever allocating, locking, or panicking.
§Architecture
- Build phase (non-RT): the caller adds nodes with
Graph::add_nodeand wires output ports to input ports withGraph::link. - Compile phase (non-RT):
Graph::compileruns Kahn’s topological sort, rejects cycles, fixes the execution order, and pre-allocates a per-port scratchAudioFramefor every node. - RT phase:
Graph::process_cycleis invoked once per audio cycle on the RT thread. It copies upstream outputs into downstream inputs (bounded, alloc-free) and calls each node’sprocessin dependency order.
§Real-time safety boundary — CRITICAL
Graph::process_cycle is the RT entry point. It MUST NOT allocate,
lock, panic, or perform any system call. This is achieved by:
- Running the topological sort in
Graph::compile(never inprocess_cycle). - Pre-allocating all scratch frames in
compile()soprocess_cyclenever grows aVec. - Using only bounded
forloops and slice indexing over pre-sized buffers.
Conformance can be verified at test time with a counting allocator that
fails on any allocation observed inside process_cycle.
§Example
A minimal two-node graph (source → gain) driven for one cycle. The source
is a no-op node whose output is seeded via Graph::feed; the gain node
scales it by 0.5:
use audio_core_bsd::{
AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
};
use audio_graph_bsd::{Graph, GraphConfig};
/// A source node with one output and a no-op `process`: its output scratch
/// is seeded externally via `Graph::feed` and left untouched each cycle.
struct SourceNode {
out_p: [PortDescriptor; 1],
}
impl SourceNode {
fn new() -> Self {
Self {
out_p: [PortDescriptor::output(1, SampleFormat::F32)],
}
}
}
impl AudioNode for SourceNode {
fn inputs(&self) -> &[PortDescriptor] { &[] }
fn outputs(&self) -> &[PortDescriptor] { &self.out_p }
fn process(&mut self, _ctx: &mut ProcessContext, _i: &[AudioFrame], _o: &mut [AudioFrame]) {}
}
/// Scales its single mono input by a fixed gain.
struct GainNode {
gain: f32,
in_p: [PortDescriptor; 1],
out_p: [PortDescriptor; 1],
}
impl GainNode {
fn new(gain: f32) -> Self {
Self {
gain,
in_p: [PortDescriptor::input(1, SampleFormat::F32)],
out_p: [PortDescriptor::output(1, SampleFormat::F32)],
}
}
}
impl AudioNode for GainNode {
fn inputs(&self) -> &[PortDescriptor] { &self.in_p }
fn outputs(&self) -> &[PortDescriptor] { &self.out_p }
fn process(&mut self, _ctx: &mut ProcessContext, i: &[AudioFrame], o: &mut [AudioFrame]) {
let (Some(inp), Some(out)) = (i.first(), o.get_mut(0)) else { return };
let n = inp.samples.len().min(out.samples.len());
for k in 0..n {
out.samples[k] = inp.samples[k] * self.gain;
}
}
}
let mut g = Graph::new();
let src = g.add_node(Box::new(SourceNode::new()));
let dst = g.add_node(Box::new(GainNode::new(0.5)));
g.link((src, 0), (dst, 0)).unwrap();
g.compile(GraphConfig::new(8, 48_000, 1)).unwrap();
// Seed the source output with all-ones (survives process_cycle because
// SourceNode::process is a no-op), run one cycle.
g.feed(src, 0, &AudioFrame::from_planar(1, 48_000, vec![1.0; 8]));
let mut ctx = ProcessContext::new(8, 0, 48_000);
g.process_cycle(&mut ctx).unwrap();
assert!(g
.read_output(dst, 0)
.unwrap()
.samples
.iter()
.all(|&x| (x - 0.5).abs() < 1e-6));Structs§
- Graph
- A real-time-safe directed acyclic graph of audio nodes.
- Graph
Config - Compile-time configuration fixing the size of every scratch buffer.
- Ring
Sink - A sink node that stashes audio from the graph for a worker thread to drain.
- Ring
Source - A source node that drains audio from a worker thread via a lock-free ring.
Enums§
- Graph
Error - Errors raised while building or compiling an audio
Graph.