Skip to main content

audio_graph_bsd/
graph.rs

1//! The real-time-safe audio graph engine.
2//!
3//! [`Graph`] owns a set of [`AudioNode`](audio_core_bsd::AudioNode) trait objects,
4//! the directed edges between their ports, and the pre-allocated scratch frames
5//! used to shuttle audio between nodes on the real-time thread. The compile /
6//! build phases allocate freely; [`Graph::process_cycle`] does not.
7
8use crate::error::GraphError;
9use crate::topology::{topological_sort, Edge};
10use audio_core_bsd::{AudioFrame, AudioNode, PortDirection, ProcessContext};
11
12/// Identifier of a node within a [`Graph`]. Stable for the lifetime of the graph.
13pub type NodeId = usize;
14
15/// Index of a port on a node, in the order reported by the node's
16/// [`inputs`](audio_core_bsd::AudioNode::inputs) /
17/// [`outputs`](audio_core_bsd::AudioNode::outputs).
18pub type PortIdx = usize;
19
20/// Identifier of a link returned by [`Graph::link`]. Equal to the link's
21/// position in insertion order.
22pub type LinkId = usize;
23
24/// Compile-time configuration fixing the size of every scratch buffer.
25///
26/// All fields are fixed at [`Graph::compile`] time so that
27/// [`Graph::process_cycle`] can rely on every buffer being pre-sized to exactly
28/// `channels * num_frames` samples.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct GraphConfig {
31    /// Number of audio frames processed per cycle (per channel).
32    pub num_frames: usize,
33    /// Sample rate in Hz.
34    pub sample_rate: u32,
35    /// Channel count carried by every port.
36    pub channels: u16,
37}
38
39impl GraphConfig {
40    /// Creates a new configuration from its raw fields.
41    #[must_use]
42    pub const fn new(num_frames: usize, sample_rate: u32, channels: u16) -> Self {
43        Self {
44            num_frames,
45            sample_rate,
46            channels,
47        }
48    }
49}
50
51/// A real-time-safe directed acyclic graph of audio nodes.
52///
53/// A `Graph` moves through three phases:
54///
55/// 1. **Build** — call [`Graph::add_node`] to register nodes and
56///    [`Graph::link`] to wire output ports to input ports.
57/// 2. **Compile** — call [`Graph::compile`] with a [`GraphConfig`]. This runs
58///    the topological sort, rejects cycles, and pre-allocates every scratch
59///    frame.
60/// 3. **Run** — call [`Graph::process_cycle`] once per audio cycle on the RT
61///    thread. Use [`Graph::feed`] to seed external inputs before a cycle and
62///    [`Graph::read_output`] / [`Graph::read_input`] to tap results after.
63///
64/// See the crate-level documentation for the real-time safety contract.
65pub struct Graph {
66    /// The nodes, indexed by [`NodeId`].
67    nodes: Vec<Box<dyn AudioNode>>,
68    /// The directed edges (output-port -> input-port).
69    edges: Vec<Edge>,
70    /// Node execution order, filled by [`Graph::compile`].
71    execution_order: Vec<NodeId>,
72    /// Compile-time configuration.
73    config: GraphConfig,
74    /// Whether [`Graph::compile`] has run.
75    compiled: bool,
76    /// Per-node, per-input-port scratch frame. Indexed `[node][port]`.
77    input_scratch: Vec<Vec<AudioFrame>>,
78    /// Per-node, per-output-port scratch frame. Indexed `[node][port]`.
79    output_scratch: Vec<Vec<AudioFrame>>,
80}
81
82impl Graph {
83    /// Creates an empty graph.
84    #[must_use]
85    pub fn new() -> Self {
86        Self {
87            nodes: Vec::new(),
88            edges: Vec::new(),
89            execution_order: Vec::new(),
90            config: GraphConfig::new(0, 0, 0),
91            compiled: false,
92            input_scratch: Vec::new(),
93            output_scratch: Vec::new(),
94        }
95    }
96
97    /// Adds a node to the graph and returns its stable [`NodeId`].
98    ///
99    /// The returned id equals the node's index in insertion order and never
100    /// changes for the lifetime of the graph. Scratch frames for the node's
101    /// ports are allocated later in [`Graph::compile`].
102    #[must_use]
103    pub fn add_node(&mut self, node: Box<dyn AudioNode>) -> NodeId {
104        let id = self.nodes.len();
105        self.nodes.push(node);
106        // Keep the scratch index aligned with the node id; the actual per-port
107        // frames are allocated in compile().
108        self.input_scratch.push(Vec::new());
109        self.output_scratch.push(Vec::new());
110        id
111    }
112
113    /// Links an output port to an input port, validating both endpoints and
114    /// their compatibility.
115    ///
116    /// The `from` port must be an **output** and the `to` port an **input**;
117    /// their channel counts and sample formats must match. The edge is stored
118    /// but the graph is **not** recompiled — call [`Graph::compile`] (or design
119    /// the full topology before compiling) before processing.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`GraphError::NodeNotFound`] if either node does not exist,
124    /// [`GraphError::PortNotFound`] if a port index is out of range,
125    /// [`GraphError::PortDirectionMismatch`] if the directions are wrong, or
126    /// [`GraphError::PortIncompatible`] if channel/format differ.
127    pub fn link(
128        &mut self,
129        from: (NodeId, PortIdx),
130        to: (NodeId, PortIdx),
131    ) -> Result<LinkId, GraphError> {
132        let (from_node, from_port) = from;
133        let (to_node, to_port) = to;
134
135        // Validate ports and directions without holding mutable borrows across
136        // the later edges.push().
137        let (from_desc, to_desc) = {
138            let from_n = self
139                .nodes
140                .get(from_node)
141                .ok_or(GraphError::NodeNotFound(from_node))?;
142            let to_n = self
143                .nodes
144                .get(to_node)
145                .ok_or(GraphError::NodeNotFound(to_node))?;
146            let from_desc = from_n
147                .outputs()
148                .get(from_port)
149                .ok_or(GraphError::PortNotFound {
150                    node: from_node,
151                    port: from_port,
152                })?;
153            let to_desc = to_n.inputs().get(to_port).ok_or(GraphError::PortNotFound {
154                node: to_node,
155                port: to_port,
156            })?;
157            (*from_desc, *to_desc)
158        };
159
160        if from_desc.direction != PortDirection::Output || to_desc.direction != PortDirection::Input
161        {
162            return Err(GraphError::PortDirectionMismatch { from, to });
163        }
164        if from_desc.channels != to_desc.channels
165            || from_desc.sample_format != to_desc.sample_format
166        {
167            return Err(GraphError::PortIncompatible { from, to });
168        }
169
170        let link_id = self.edges.len();
171        self.edges.push(Edge { from, to });
172        Ok(link_id)
173    }
174
175    /// Compiles the graph: topologically sorts the nodes and pre-allocates every
176    /// scratch frame.
177    ///
178    /// This is the only place allocation is permitted. After `compile`
179    /// succeeds, [`Graph::process_cycle`] is guaranteed to be allocation-free.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`GraphError::AlreadyCompiled`] if called twice, or
184    /// [`GraphError::CycleDetected`] if the topology contains a cycle.
185    pub fn compile(&mut self, config: GraphConfig) -> Result<(), GraphError> {
186        if self.compiled {
187            return Err(GraphError::AlreadyCompiled);
188        }
189        let order = topological_sort(self.nodes.len(), &self.edges)
190            .map_err(|remaining| GraphError::CycleDetected { nodes: remaining })?;
191        self.execution_order = order;
192        self.config = config;
193
194        // Pre-allocate every per-port scratch frame so process_cycle never
195        // allocates. This is the ONLY place allocation is permitted.
196        self.input_scratch = Vec::with_capacity(self.nodes.len());
197        self.output_scratch = Vec::with_capacity(self.nodes.len());
198        for node in &self.nodes {
199            let in_slots: Vec<AudioFrame> = node
200                .inputs()
201                .iter()
202                .map(|_| {
203                    AudioFrame::silence(config.channels, config.num_frames, config.sample_rate)
204                })
205                .collect();
206            let out_slots: Vec<AudioFrame> = node
207                .outputs()
208                .iter()
209                .map(|_| {
210                    AudioFrame::silence(config.channels, config.num_frames, config.sample_rate)
211                })
212                .collect();
213            self.input_scratch.push(in_slots);
214            self.output_scratch.push(out_slots);
215        }
216        self.compiled = true;
217        Ok(())
218    }
219
220    /// Processes one audio cycle on the real-time thread.
221    ///
222    /// For each node in dependency order this copies connected upstream outputs
223    /// into the node's input scratch (or zeroes unconnected inputs), then
224    /// invokes the node's
225    /// [`process`](audio_core_bsd::AudioNode::process). The whole pass is bounded
226    /// and allocation-free: every slice is pre-sized by [`Graph::compile`] and
227    /// only bounded `for` loops / slice copies are used.
228    ///
229    /// # Real-time safety
230    ///
231    /// This method performs **no** allocation, locking, panicking, or system
232    /// call. The single `Err(NotCompiled)` return on the uncompiled path is a
233    /// stack-only branch (the variant carries no heap data); on the happy path
234    /// the method returns `Ok(())`.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`GraphError::NotCompiled`] if [`Graph::compile`] has not been
239    /// called.
240    pub fn process_cycle(&mut self, ctx: &mut ProcessContext) -> Result<(), GraphError> {
241        if !self.compiled {
242            return Err(GraphError::NotCompiled);
243        }
244
245        // Split the &mut self borrow into disjoint field borrows so the borrow
246        // checker allows reading output_scratch while writing input_scratch and
247        // invoking nodes[i] in the same loop.
248        let Graph {
249            nodes,
250            edges,
251            execution_order,
252            input_scratch,
253            output_scratch,
254            ..
255        } = self;
256
257        for &n in execution_order.iter() {
258            // (a) Fill this node's input slots from upstream outputs, or zero them.
259            let Some(in_slots) = input_scratch.get_mut(n) else {
260                continue;
261            };
262            for (pi, slot) in in_slots.iter_mut().enumerate() {
263                let mut sourced = false;
264                for edge in edges.iter() {
265                    if edge.to == (n, pi) {
266                        let (src, src_port) = edge.from;
267                        if let Some(src_slots) = output_scratch.get(src) {
268                            if let Some(src_frame) = src_slots.get(src_port) {
269                                slot.channels = src_frame.channels;
270                                slot.sample_rate = src_frame.sample_rate;
271                                let copy_len = src_frame.samples.len().min(slot.samples.len());
272                                // Bounded, alloc-free copy over pre-sized slices.
273                                slot.samples[..copy_len]
274                                    .copy_from_slice(&src_frame.samples[..copy_len]);
275                            }
276                        }
277                        sourced = true;
278                        break;
279                    }
280                }
281                if !sourced {
282                    // No upstream edge: feed silence.
283                    for s in &mut slot.samples {
284                        *s = 0.0;
285                    }
286                }
287            }
288
289            // (b) Invoke the node over the filled input slots and its output scratch.
290            let Some(out_slots) = output_scratch.get_mut(n) else {
291                continue;
292            };
293            let Some(node) = nodes.get_mut(n) else {
294                continue;
295            };
296            node.process(ctx, in_slots.as_slice(), out_slots.as_mut_slice());
297        }
298
299        Ok(())
300    }
301
302    /// Seeds a node's output port from an external frame, before a cycle.
303    ///
304    /// Performs a bounded copy into the pre-sized scratch slot — no
305    /// reallocation. Used to inject audio into a source node's output before
306    /// calling [`Graph::process_cycle`]. Out-of-range node/port is a silent
307    /// no-op (never panics).
308    pub fn feed(&mut self, node: NodeId, port: PortIdx, src: &AudioFrame) {
309        if let Some(slots) = self.output_scratch.get_mut(node) {
310            if let Some(dst) = slots.get_mut(port) {
311                dst.channels = src.channels;
312                dst.sample_rate = src.sample_rate;
313                let copy_len = src.samples.len().min(dst.samples.len());
314                dst.samples[..copy_len].copy_from_slice(&src.samples[..copy_len]);
315            }
316        }
317    }
318
319    /// Borrows a node's output frame after a cycle (tapping a node's output).
320    ///
321    /// Returns `None` if the node or port is out of range — never panics.
322    #[must_use]
323    pub fn read_output(&self, node: NodeId, port: PortIdx) -> Option<&AudioFrame> {
324        self.output_scratch.get(node).and_then(|s| s.get(port))
325    }
326
327    /// Borrows the input frame that reached a node after a cycle.
328    ///
329    /// Sinks have zero outputs, so callers read a sink's consumed audio through
330    /// its input slot. Returns `None` if the node or port is out of range.
331    #[must_use]
332    pub fn read_input(&self, node: NodeId, port: PortIdx) -> Option<&AudioFrame> {
333        self.input_scratch.get(node).and_then(|s| s.get(port))
334    }
335
336    /// Returns the number of nodes in the graph.
337    #[must_use]
338    pub fn node_count(&self) -> usize {
339        self.nodes.len()
340    }
341
342    /// Returns the number of links in the graph.
343    #[must_use]
344    pub fn link_count(&self) -> usize {
345        self.edges.len()
346    }
347
348    /// Returns `true` if the graph has been compiled.
349    #[must_use]
350    pub fn is_compiled(&self) -> bool {
351        self.compiled
352    }
353
354    /// Returns the compile-time [`GraphConfig`].
355    ///
356    /// Before [`Graph::compile`] this returns the default (zeroed) config.
357    #[must_use]
358    pub fn config(&self) -> GraphConfig {
359        self.config
360    }
361}
362
363impl Default for Graph {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use audio_core_bsd::{
373        AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
374    };
375
376    /// A minimal mono gain node for tests: 1 in, 1 out, scales by `gain`.
377    struct GainNode {
378        gain: f32,
379        in_port: [PortDescriptor; 1],
380        out_port: [PortDescriptor; 1],
381    }
382
383    impl GainNode {
384        fn new(gain: f32) -> Self {
385            Self {
386                gain,
387                in_port: [PortDescriptor::new(
388                    PortDirection::Input,
389                    1,
390                    SampleFormat::F32,
391                )],
392                out_port: [PortDescriptor::new(
393                    PortDirection::Output,
394                    1,
395                    SampleFormat::F32,
396                )],
397            }
398        }
399    }
400
401    impl AudioNode for GainNode {
402        fn inputs(&self) -> &[PortDescriptor] {
403            &self.in_port
404        }
405        fn outputs(&self) -> &[PortDescriptor] {
406            &self.out_port
407        }
408        fn process(
409            &mut self,
410            _ctx: &mut ProcessContext,
411            in_frames: &[AudioFrame],
412            out_frames: &mut [AudioFrame],
413        ) {
414            let Some(inp) = in_frames.first() else {
415                return;
416            };
417            let Some(out) = out_frames.get_mut(0) else {
418                return;
419            };
420            let n = inp.samples.len().min(out.samples.len());
421            for i in 0..n {
422                out.samples[i] = inp.samples[i] * self.gain;
423            }
424        }
425    }
426
427    /// A test source node: zero inputs, one output, and a no-op `process`.
428    ///
429    /// Because `process` never touches the output scratch, whatever
430    /// [`Graph::feed`] wrote into the source's output port survives the cycle
431    /// and flows downstream — exactly the behaviour a real source/gateway has.
432    struct SourceNode {
433        out_port: [PortDescriptor; 1],
434    }
435
436    impl SourceNode {
437        fn new(channels: u16) -> Self {
438            Self {
439                out_port: [PortDescriptor::new(
440                    PortDirection::Output,
441                    channels,
442                    SampleFormat::F32,
443                )],
444            }
445        }
446    }
447
448    impl AudioNode for SourceNode {
449        fn inputs(&self) -> &[PortDescriptor] {
450            &[]
451        }
452        fn outputs(&self) -> &[PortDescriptor] {
453            &self.out_port
454        }
455        fn process(
456            &mut self,
457            _ctx: &mut ProcessContext,
458            _in_frames: &[AudioFrame],
459            _out_frames: &mut [AudioFrame],
460        ) {
461            // Intentionally a no-op: the output scratch is seeded via Graph::feed.
462        }
463    }
464
465    /// Approximate float equality for assertions (uses `<`, never `==`).
466    fn approx_eq(a: f32, b: f32) -> bool {
467        (a - b).abs() < 1e-6
468    }
469
470    #[test]
471    fn empty_graph_compiles_and_runs() {
472        let mut g = Graph::new();
473        assert_eq!(g.node_count(), 0);
474        assert_eq!(g.link_count(), 0);
475        g.compile(GraphConfig::new(64, 48_000, 1)).unwrap();
476        let mut ctx = ProcessContext::new(64, 0, 48_000);
477        g.process_cycle(&mut ctx).unwrap();
478    }
479
480    #[test]
481    fn default_equals_new() {
482        let a = Graph::new();
483        let b = Graph::default();
484        assert_eq!(a.node_count(), b.node_count());
485        assert_eq!(a.link_count(), b.link_count());
486    }
487
488    #[test]
489    fn add_node_returns_sequential_ids() {
490        let mut g = Graph::new();
491        let a = g.add_node(Box::new(GainNode::new(1.0)));
492        let b = g.add_node(Box::new(GainNode::new(1.0)));
493        assert_eq!(a, 0);
494        assert_eq!(b, 1);
495        assert_eq!(g.node_count(), 2);
496    }
497
498    #[test]
499    fn link_valid_ports_succeeds() {
500        let mut g = Graph::new();
501        let s = g.add_node(Box::new(GainNode::new(1.0)));
502        let d = g.add_node(Box::new(GainNode::new(1.0)));
503        let link = g.link((s, 0), (d, 0)).unwrap();
504        assert_eq!(link, 0);
505        assert_eq!(g.link_count(), 1);
506    }
507
508    #[test]
509    fn link_unknown_node_errors() {
510        let mut g = Graph::new();
511        let s = g.add_node(Box::new(GainNode::new(1.0)));
512        assert_eq!(g.link((s, 0), (99, 0)), Err(GraphError::NodeNotFound(99)));
513        assert_eq!(g.link((99, 0), (s, 0)), Err(GraphError::NodeNotFound(99)));
514    }
515
516    #[test]
517    fn link_bad_port_errors() {
518        let mut g = Graph::new();
519        let s = g.add_node(Box::new(GainNode::new(1.0)));
520        let d = g.add_node(Box::new(GainNode::new(1.0)));
521        assert_eq!(
522            g.link((s, 5), (d, 0)),
523            Err(GraphError::PortNotFound { node: s, port: 5 })
524        );
525        assert_eq!(
526            g.link((s, 0), (d, 9)),
527            Err(GraphError::PortNotFound { node: d, port: 9 })
528        );
529    }
530
531    #[test]
532    fn process_before_compile_errors() {
533        let mut g = Graph::new();
534        let mut ctx = ProcessContext::new(64, 0, 48_000);
535        assert_eq!(g.process_cycle(&mut ctx), Err(GraphError::NotCompiled));
536    }
537
538    #[test]
539    fn compile_twice_errors() {
540        let mut g = Graph::new();
541        g.compile(GraphConfig::new(64, 48_000, 1)).unwrap();
542        assert_eq!(
543            g.compile(GraphConfig::new(64, 48_000, 1)),
544            Err(GraphError::AlreadyCompiled)
545        );
546    }
547
548    #[test]
549    fn cycle_is_rejected_at_compile() {
550        let mut g = Graph::new();
551        let a = g.add_node(Box::new(GainNode::new(1.0)));
552        let b = g.add_node(Box::new(GainNode::new(1.0)));
553        g.link((a, 0), (b, 0)).unwrap();
554        g.link((b, 0), (a, 0)).unwrap();
555        let err = g.compile(GraphConfig::new(64, 48_000, 1)).unwrap_err();
556        assert!(matches!(err, GraphError::CycleDetected { .. }));
557    }
558
559    #[test]
560    fn end_to_end_gain_chain() {
561        let mut g = Graph::new();
562        let src = g.add_node(Box::new(SourceNode::new(1))); // seeded via feed
563        let mid = g.add_node(Box::new(GainNode::new(0.5))); // half gain
564        g.link((src, 0), (mid, 0)).unwrap();
565        g.compile(GraphConfig::new(8, 48_000, 1)).unwrap();
566
567        // Seed the source output with all-ones (survives process_cycle because
568        // SourceNode::process is a no-op).
569        g.feed(src, 0, &AudioFrame::from_planar(1, 48_000, vec![1.0; 8]));
570        let mut ctx = ProcessContext::new(8, 0, 48_000);
571        g.process_cycle(&mut ctx).unwrap();
572
573        // mid input should mirror src output (1.0); mid output should be 0.5.
574        let mid_in = g.read_input(mid, 0).unwrap();
575        assert!(mid_in.samples.iter().all(|&s| approx_eq(s, 1.0)));
576        let mid_out = g.read_output(mid, 0).unwrap();
577        assert!(mid_out.samples.iter().all(|&s| approx_eq(s, 0.5)));
578    }
579
580    #[test]
581    fn three_stage_chain_composes_gains() {
582        let mut g = Graph::new();
583        let src = g.add_node(Box::new(SourceNode::new(1)));
584        let a = g.add_node(Box::new(GainNode::new(2.0)));
585        let b = g.add_node(Box::new(GainNode::new(3.0)));
586        let c = g.add_node(Box::new(GainNode::new(0.5)));
587        g.link((src, 0), (a, 0)).unwrap();
588        g.link((a, 0), (b, 0)).unwrap();
589        g.link((b, 0), (c, 0)).unwrap();
590        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
591
592        g.feed(src, 0, &AudioFrame::from_planar(1, 48_000, vec![1.0; 4]));
593        let mut ctx = ProcessContext::new(4, 0, 48_000);
594        g.process_cycle(&mut ctx).unwrap();
595
596        // 1.0 * 2.0 * 3.0 * 0.5 = 3.0
597        let out = g.read_output(c, 0).unwrap();
598        assert!(out.samples.iter().all(|&s| approx_eq(s, 3.0)));
599    }
600
601    #[test]
602    fn unconnected_input_is_silenced() {
603        let mut g = Graph::new();
604        let n = g.add_node(Box::new(GainNode::new(1.0)));
605        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
606        let mut ctx = ProcessContext::new(4, 0, 48_000);
607        g.process_cycle(&mut ctx).unwrap();
608        let inp = g.read_input(n, 0).unwrap();
609        assert!(inp.samples.iter().all(|&s| approx_eq(s, 0.0)));
610    }
611
612    #[test]
613    fn read_out_of_range_returns_none() {
614        let mut g = Graph::new();
615        let n = g.add_node(Box::new(GainNode::new(1.0)));
616        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
617        assert!(g.read_output(99, 0).is_none());
618        assert!(g.read_output(n, 99).is_none());
619        assert!(g.read_input(99, 0).is_none());
620        assert!(g.read_input(n, 99).is_none());
621    }
622
623    #[test]
624    fn feed_out_of_range_is_noop() {
625        let mut g = Graph::new();
626        let _n = g.add_node(Box::new(GainNode::new(1.0)));
627        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
628        // Must not panic.
629        g.feed(99, 0, &AudioFrame::silence(1, 4, 48_000));
630        g.feed(0, 99, &AudioFrame::silence(1, 4, 48_000));
631    }
632
633    #[test]
634    fn graphconfig_new_and_equality() {
635        let a = GraphConfig::new(256, 48_000, 2);
636        assert_eq!(a, GraphConfig::new(256, 48_000, 2));
637        assert_ne!(a, GraphConfig::new(128, 48_000, 2));
638        assert_eq!(a.num_frames, 256);
639        assert_eq!(a.sample_rate, 48_000);
640        assert_eq!(a.channels, 2);
641    }
642
643    #[test]
644    fn is_compiled_and_config_reflect_state() {
645        let mut g = Graph::new();
646        assert!(!g.is_compiled());
647        g.compile(GraphConfig::new(4, 44_100, 2)).unwrap();
648        assert!(g.is_compiled());
649        assert_eq!(g.config(), GraphConfig::new(4, 44_100, 2));
650    }
651
652    #[test]
653    fn diamond_topology_processes_in_dependency_order() {
654        // src(feed 1.0) -> {a(*2), b(*3)} ; a -> sink(*1).
655        // b's output is unconnected (exercises the unconnected-output path).
656        let mut g = Graph::new();
657        let src = g.add_node(Box::new(SourceNode::new(1)));
658        let a = g.add_node(Box::new(GainNode::new(2.0)));
659        let b = g.add_node(Box::new(GainNode::new(3.0)));
660        let sink = g.add_node(Box::new(GainNode::new(1.0)));
661        g.link((src, 0), (a, 0)).unwrap();
662        g.link((src, 0), (b, 0)).unwrap();
663        g.link((a, 0), (sink, 0)).unwrap();
664        let _ = b;
665        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
666
667        g.feed(src, 0, &AudioFrame::from_planar(1, 48_000, vec![1.0; 4]));
668        let mut ctx = ProcessContext::new(4, 0, 48_000);
669        g.process_cycle(&mut ctx).unwrap();
670        let out = g.read_output(sink, 0).unwrap();
671        // sink input == a output == 1.0 * 2.0 = 2.0; sink gain 1.0 -> 2.0.
672        assert!(out.samples.iter().all(|&s| approx_eq(s, 2.0)));
673    }
674}