primitives::graph
The audio process graph of the Superb DAW SDK.
Purpose
primitives::graph is the middle of the engine: it depends on the
primitives root, primitives::buffers, primitives::realtime and
primitives::midi, and pulls primitives::transport in for
BlockTime::from_transport. It defines the Node trait every processor
implements and AudioGraph, which owns nodes and edges, compiles a
topological schedule, allocates all buffers up front in prepare, and then
runs process with no heap activity. primitives::mixer builds its console
on this layer and the block loop in primitives::io drives it.
Key types
Node— the processor trait:channels()(fixed for the node's lifetime),prepare(off-thread, may allocate),reset,process(audio thread, must not allocate),latency_frames(read afterpreparefor latency compensation, see below).NodeChannels— a node's single input/output bus channel counts (symmetricorgenerator).ProcessStatus—NormalorSilent, returned byprocessso the graph can skip mixing a silent producer into its consumers.AudioGraph— the container:add_node/remove_node/connect/disconnect/set_output(editing, off the audio thread),prepare(compiles + allocates),process(audio thread).Edge/GraphStats— a producer→consumer connection, and per-processcounters (nodes_processed,nodes_silent,mixes,delayed_mixes).- Latency:
set_latency_compensation/latency_compensation,latency_frames(graph total),node_input_latency/node_output_latency,edge_delay(see below). MidiPort/MidiEdge— a MIDI source (MidiPort::External, the buffer passed toprocess) routed to one node; edited withconnect_midi/disconnect_midi/midi_edges(see below).ProcessContext/BlockTime— what a node sees each block:input(the engine's live audio input, when one is attached), sample rate, frame count, timeline position/tempo/rolling/wrapped state, the block'sMidiEventBuffer, andinput_is_silent.nodes—SineNode,GainNode(click-free ramped gain),PassThroughNode,MonoSynthNode,DelayNode(a fixed delay that reports itself as latency), and the test fixturesConstantNodeandSilenceNode. The session buildsBufferPlayerNode,ScheduledSynthNode,LiveInputNodeandClickNodefrom this module; see below.dynamicsmodule — compressor, true-peak limiter with lookahead, gate and expander, de-esser, leveler, as nodes driven by sharedParams; see dynamics.md.
Usage
use superb::primitives::SampleRate;
use superb::primitives::graph::{AudioGraph, BlockTime};
use superb::primitives::graph::nodes::{GainNode, SineNode};
use superb::primitives::midi::MidiEventBuffer;
let mut graph = AudioGraph::new();
let sine = graph.add_node(Box::new(SineNode::new(440.0, 0.5, 1)));
let gain = graph.add_node(Box::new(GainNode::new(0.5, 1)));
graph.connect(sine, gain).unwrap();
graph.set_output(gain).unwrap();
graph.prepare(SampleRate::HZ_48K, 256).unwrap();
let midi = MidiEventBuffer::with_capacity(0);
graph.process(BlockTime::free_running(), 256, &midi, None).unwrap();
let out = graph.output();
assert_eq!(out.channels(), 1);
assert!(out.peak() <= 0.25 + 1e-6);Editing after prepare (add_node, connect, ...) sets graph.is_dirty();
process then returns Err(SuperbError::NotPrepared) until prepare runs
again.
MIDI edges: per-node MIDI routing
Audio edges say where samples flow; MIDI edges say which nodes see the
MidiEventBuffer handed to process. They are a separate edge set
(MidiEdge { from: MidiPort, to: NodeId }) that never participates in the
audio schedule or cycle detection.
use superb::primitives::SampleRate;
use superb::primitives::graph::nodes::{MonoSynthNode, PassThroughNode};
use superb::primitives::graph::{AudioGraph, BlockTime, MidiEdge, MidiPort};
use superb::primitives::midi::{MidiChannel, MidiEvent, MidiEventBuffer, MidiMessage};
let mut graph = AudioGraph::new();
let lead = graph.add_node(Box::new(MonoSynthNode::new(1)));
let pad = graph.add_node(Box::new(MonoSynthNode::new(1)));
let sink = graph.add_node(Box::new(PassThroughNode::new(1)));
graph.connect(lead, sink).unwrap();
graph.connect(pad, sink).unwrap();
graph.set_output(sink).unwrap();
// Only `lead` hears external MIDI.
graph.connect_midi(MidiPort::External, lead).unwrap();
assert_eq!(graph.midi_edges(), &[MidiEdge { from: MidiPort::External, to: lead }]);
graph.prepare(SampleRate::HZ_48K, 64).unwrap();
let ch = MidiChannel::new(0).unwrap();
let mut midi = MidiEventBuffer::with_capacity(4);
midi.push(MidiEvent { frame: 0, message: MidiMessage::NoteOn { channel: ch, note: 69, velocity: 127 } }).unwrap();
graph.process(BlockTime::free_running(), 64, &midi, None).unwrap();
assert!(graph.node_output(lead).unwrap().peak() > 0.9);
assert!(graph.node_output(pad).unwrap().is_silent());Rules:
- Broadcast when empty. With no MIDI edges every node receives the incoming buffer, so a graph that never routes MIDI needs no edges at all. Adding the first MIDI edge switches the graph to routed mode; removing the last one switches back.
- Routed mode. Nodes targeted by a
MidiPort::Externaledge get the incoming buffer; every other node gets a pre-allocated empty buffer (AudioGraph::empty_midi, capacity 0, created innew). There is no per-node copy or channel filtering in v1: the choice is a pointer select. - Compiled, not scanned.
preparebuilds a per-slotreceives_external_midi: Vec<bool>table soprocessdoes O(1) work per node and never touches the edge list on the audio thread. Like audio edits,connect_midi/disconnect_midimark the graph dirty and requireprepare(or, withLiveGraph, a freshpublish). - Errors.
connect_midifails withNotFoundfor an unknown node andInvalidArgumentfor a duplicate edge;disconnect_midifails withNotFoundif the edge does not exist.remove_nodedrops the MIDI edges that target the removed node. MidiPortis#[non_exhaustive]: a node-to-node port (MIDI effects, arpeggiators feeding synths) can be added later without breaking callers.Mixerexposes the graph throughgraph_mut(), somixer.graph_mut().connect_midi(...)is the mixer-level API for now.
See examples/midi_route for MIDI edges plus sample-accurate placement of a
queued note through superb::primitives::io::Engine.
Live editing: live_pair, GraphEditor, LiveGraph
AudioGraph alone forces a stop/prepare/start cycle for every structural
edit because process refuses to run a dirty graph. The live module lifts
that restriction without locks: the control thread edits a draft graph and
publishes whole prepared graphs; the audio thread swaps them in between
blocks.
use superb::primitives::SampleRate;
use superb::primitives::graph::nodes::{GainNode, SineNode};
use superb::primitives::graph::{live_pair, BlockTime};
use superb::primitives::midi::MidiEventBuffer;
let (mut editor, mut live) = live_pair(); // SPSC-connected pair
editor.configure(SampleRate::HZ_48K, 256); // config every publish is prepared with
let s = editor.graph_mut().add_node(Box::new(SineNode::new(440.0, 1.0, 1)));
let g = editor.graph_mut().add_node(Box::new(GainNode::new(0.5, 1)));
editor.graph_mut().connect(s, g).unwrap();
editor.graph_mut().set_output(g).unwrap();
editor.publish().unwrap(); // prepare + enqueue, draft becomes empty
let midi = MidiEventBuffer::with_capacity(0);
live.process(BlockTime::free_running(), 256, &midi, None).unwrap(); // applies the swap, then renders
assert_eq!(live.swap_count(), 1);
// Keep processing while building the next generation on the draft ...
let c = editor.graph_mut().add_node(Box::new(superb::primitives::graph::nodes::ConstantNode::new(0.25, 1)));
editor.graph_mut().set_output(c).unwrap();
editor.publish().unwrap();
live.process(BlockTime::free_running(), 256, &midi, None).unwrap();
assert_eq!(live.output().channel(0)[0], 0.25);Protocol and ownership:
- Exactly one active
Box<AudioGraph>lives insideLiveGraph; the editor owns the draft. Twosuperb::primitives::realtime::spscqueues connect them: a swap queue (GraphCommand::Swap(Box<AudioGraph>), editor → live, capacity 1 by default,live_pair_with_capacity(n)for more) and a recycle queue (live → editor, capacityn + 1). - Dirty vs. published. Edits on the draft (
editor.graph_mut()) mark it dirty as usual.publish()reclaims retired graphs, callsAudioGraph::prepareon the draft with the configured sample rate / block size, moves the prepared graph into the swap queue and resets the draft toAudioGraph::new(). The active graph is never dirty: only prepared graphs cross the boundary. Editinglive.graph_mut()structurally would dirty it and makeprocessfail, so don't. - Errors.
publishfails withInvalidArgumentifconfigurewas not called,CapacityExceededif the previous publish has not been applied yet (editor.is_busy()), or whateverpreparereturns (e.g.Cycle). In every case the draft is left intact for a retry. - Audio thread.
LiveGraph::processfirst callsapply_pending: pop aSwap,mem::swapit with the active graph, push the retired graph to the recycle queue. Pointer moves only, no allocation, proven bytests/live_edit.rsboth with and without a pending swap. If several swaps are queued the newest ends up active and the intermediates are recycled. - Nothing is dropped on the audio thread. Retired graphs go back to the
editor and are dropped by
publish()ortry_reclaim();take_retired()hands one back instead so nodes can be pulled out withremove_nodeand reused. If the recycle queue is ever full, the retired graph is parked in anorphanslot and no further swaps are applied until it has been handed back. - Configuration must match. A published graph prepared with a different
(SampleRate, max_frames)than the live side is rejected: it is recycled without being activated and counted inrejected_count()on both halves.LiveGraph::prepare(sr, frames)(alsoBlockProcessor::prepare, so anEnginecan own aLiveGraph) applies whatever is pending first, prepares the active graph with the new configuration, and fixes that configuration for future swaps; the first accepted swap into an unpreparedLiveGraphadopts the incoming configuration. - Shared
AtomicU64counters let the editor observe progress without a channel:published_count(),applied_count(),rejected_count(),pending_count().
See examples/live_graph_edit for three generations swapped into a running
block loop with printed peaks.
Latency compensation
Every Node reports latency_frames() (a look-ahead limiter, a linear-phase
EQ, a plugin). With compensation on — the default, toggled by
set_latency_compensation — prepare runs a latency pass after preparing
the nodes (so a node may only know its latency once prepared):
- Walking the schedule, each node's input latency is the largest output
latency among its producers, and its output latency is that plus its
own
latency_frames(). - Every edge whose producer's output latency is smaller than the consumer's
input latency gets a delay line of the difference, allocated right there
(
delay + max_framesframes per producer channel plus a scratch block). latency_frames()is the output node's output latency — the longest path — and, with compensation on, every path to the output has exactly that latency, so parallel paths stay phase-aligned.
use superb::primitives::SampleRate;
use superb::primitives::graph::nodes::{DelayNode, PassThroughNode, SineNode};
use superb::primitives::graph::AudioGraph;
let mut g = AudioGraph::new();
let src = g.add_node(Box::new(SineNode::new(440.0, 0.5, 1)));
let limiter = g.add_node(Box::new(DelayNode::new(64, 1))); // 64-frame look-ahead
let sum = g.add_node(Box::new(PassThroughNode::new(1)));
g.connect(src, limiter).unwrap();
g.connect(limiter, sum).unwrap();
g.connect(src, sum).unwrap(); // dry path in parallel
g.set_output(sum).unwrap();
g.prepare(SampleRate::HZ_48K, 256).unwrap();
assert_eq!(g.latency_frames(), 64);
assert_eq!(g.edge_delay(src, sum).unwrap(), 64); // dry path delayed to match
assert_eq!(g.edge_delay(limiter, sum).unwrap(), 0);In process a delayed edge always advances its delay line (even while the
producer is silent, so history stays aligned with the timeline), reads the
delayed block into the edge's scratch buffer and mixes that with the usual
channel rule. Silence propagation still works: an edge is skipped once only
silence has been pushed for delay + frames frames (GraphStats::delayed_mixes
counts the ones that were mixed). reset() clears the delay lines too.
BlockProcessor::latency_frames exposes the total through LiveGraph and
Mixer so an engine can report or pre-roll it.
Real-time notes
AudioGraph::add_node/remove_node/connect/disconnect/prepare all
call debug_assert_not_rt and allocate (Vec<PlanarBuffer> per node,
HashMap/Vec bookkeeping) — they are the off-thread editing/compile path.
AudioGraph::process performs no heap activity: this is proven directly
by tests/graph_no_alloc.rs::process_is_allocation_free, which
runs a SineNode+MonoSynthNode+GainNode+PassThroughNode graph for 64
varying-size blocks inside superb::primitives::realtime::assert_no_alloc with
CountingAllocator installed. Per-node process implementations reuse the
pre-allocated input/output PlanarBuffers (input.set_frames/clear,
output.set_frames, mix_into via add_channel_from) rather than
allocating new ones. MIDI is delivered as a &MidiEventBuffer reference, not
copied; the same test includes a MIDI edge so the routed path (pointer
select between the incoming buffer and empty_midi) is covered by the
no-alloc proof too, and a DelayNode in a parallel path so a
latency-compensation delay line (ring write + delayed read + mix, and
reset) is exercised under the proof as well.
Session playback nodes
BufferPlayerNode— timeline-aware playback of anArc<PlanarBuffer>(region, offset, gain, fades); silent when the transport is stopped.ScheduledSynthNode/ScheduledNoteEvent— monophonic sine synth driven by absolute-sample note on/off events (built from session MIDI clips via the session's wiring).LiveInputNode— plays chosen channels of the engine's live input (ProcessContext::input).build_mixerplaces one on each monitoring audio track.ClickNode/ClickParams— the metronome click, driven by the block's beat position.ClickParamsis the live level and on/off.build_mixerplaces one on the master.
Limitations / next
- Single input bus and single output bus per node —
NodeChannelshas exactly oneinputs/outputscount; there is no multi-bus (e.g. separate sidechain input) support. - MIDI routing is on/off per node, external source only.
MidiPorthas a single variant (External), there is no node-to-node MIDI (no MIDI effects), and a routed node sees the whole block buffer — no channel or note-range filtering and no per-node copy. Without any MIDI edge the buffer is still broadcast to every node. - Latency compensation is per edge and static. Delays are computed at
prepare; a node whose latency changes at run time (plugin oversampling toggled) needs a re-prepare/re-publish. There is no negative-latency ("pre-roll the sources") mode: the graph's total latency is reported for the application to handle. The delayed-edge silence flag is conservative (based on pushed silence, not buffer content). - Channel adaptation is a fixed rule, not configurable: equal counts map
1:1, mono producers fan out, mono consumers average, otherwise only the
first
min(src, dst)channels map (mix_into,src/primitives/graph/audio_graph.rs, tested bychannel_adaptation_rules). - Cycles are rejected at
connecttime (SuperbError::Cycle), so there is no feedback-delay-compensated loop support. - Live editing swaps whole graphs. After
publish()the draft is empty, so the next generation must be rebuilt (or assembled from nodes recovered viatake_retired()+remove_node). Node state (oscillator phase, gain ramps) does not carry over across a swap, and swaps land on block boundaries only, so a click-free crossfade between generations is the caller's responsibility for now.