audio-graph-bsd 0.3.0

Real-time-safe directed node-graph audio processing engine for Rust (topological scheduling, alloc-free process_cycle, rtrb bridge) — FreeBSD-oriented
Documentation

audio-graph-bsd

License: BSD-2-Clause Crates.io

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:

  1. Build — [Graph::add_node] registers nodes and [Graph::link] wires output ports to input ports (with direction / channel / format validation).
  2. Compile — [Graph::compile] runs the topological sort, rejects cycles, and pre-allocates every per-port scratch frame. Allocation is only permitted here.
  3. 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 audio_core_bsd::{
    AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
};
use audio_graph_bsd::{Graph, GraphConfig};

// A source node: one mono output, 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]) {}
}

// A gain node: one mono input, one mono output, scales by `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;
        }
    }
}

// 1. Build: add a source and a gain node, wire src:0 -> gain:0.
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();

// 2. Compile: topological sort + pre-allocate every scratch frame.
g.compile(GraphConfig::new(8, 48_000, 1)).unwrap();

// 3. Run: seed the source, drive one cycle, read the scaled output.
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));

A larger, runnable version of this pattern — streaming a 440 Hz sine through multiple cycles — lives at examples/simple_route.rs:

cargo run --example simple_route

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 in process_cycle.
  • All per-port scratch frames are pre-sized in compile(), so process_cycle never grows a Vec.
  • process_cycle uses only bounded for loops 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.