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 std::cell::UnsafeCell;
9
10use crate::error::GraphError;
11use crate::flush::{FlushError, SinkNode};
12use crate::topology::{topological_sort, Edge};
13use audio_core_bsd::{AudioFrame, AudioNode, PortDirection, ProcessContext};
14
15// The `topology` feature brings in the serializable snapshot model and the
16// subscriber channel used to emit TopologyEvent on the non-RT mutation path.
17#[cfg(feature = "topology")]
18use crate::topology_pub::{
19    NodeSnapshot, PortMeta, SnapshotEdge, SnapshotSource, TopologyEvent, TopologySnapshot,
20};
21
22// The `distributed` feature (which implies `topology`) brings in the
23// distributed-prep models used by `partition_hints` below.
24#[cfg(feature = "distributed")]
25use crate::distributed::{BoundaryPort, PartitionHint, PortKind, RemoteNode};
26#[cfg(feature = "distributed")]
27use crate::topology_pub::PortDir;
28
29/// Identifier of a node within a [`Graph`]. Stable for the lifetime of the graph.
30pub type NodeId = usize;
31
32/// Index of a port on a node, in the order reported by the node's
33/// [`inputs`](audio_core_bsd::AudioNode::inputs) /
34/// [`outputs`](audio_core_bsd::AudioNode::outputs).
35pub type PortIdx = usize;
36
37/// Identifier of a link returned by [`Graph::link`]. Equal to the link's
38/// position in insertion order.
39pub type LinkId = usize;
40
41/// Compile-time configuration fixing the size of every scratch buffer.
42///
43/// All fields are fixed at [`Graph::compile`] time so that
44/// [`Graph::process_cycle`] can rely on every buffer being pre-sized to exactly
45/// `channels * num_frames` samples.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct GraphConfig {
48    /// Number of audio frames processed per cycle (per channel).
49    pub num_frames: usize,
50    /// Sample rate in Hz.
51    pub sample_rate: u32,
52    /// Channel count carried by every port.
53    pub channels: u16,
54}
55
56impl GraphConfig {
57    /// Creates a new configuration from its raw fields.
58    #[must_use]
59    pub const fn new(num_frames: usize, sample_rate: u32, channels: u16) -> Self {
60        Self {
61            num_frames,
62            sample_rate,
63            channels,
64        }
65    }
66}
67
68/// The real-time-mutated state of a [`Graph`], held behind a single
69/// [`UnsafeCell`].
70///
71/// All three fields are touched on every [`Graph::process_cycle`]:
72/// `nodes` via [`AudioNode::process`](audio_core_bsd::AudioNode::process)
73/// (which takes `&mut self`), and the scratch frame vectors via the
74/// upstream-output copy. `nodes` lives here — rather than as a plain field
75/// — because calling `process(&mut self, …)` from a `&self` entry point is
76/// impossible without interior mutability; bundling it with the scratch
77/// vectors keeps the whole RT-mutated surface behind **one** cell.
78///
79/// # Soundness
80///
81/// The [`UnsafeCell`] wrapping this struct is sound because the real-time
82/// thread is the *sole* mutator during processing, and the caller guarantees
83/// that no `process_cycle` / `compile` / `feed` runs concurrently on the same
84/// [`Graph`] (single-RT-thread invariant). Every `&mut self` build/compile
85/// method uses the safe [`UnsafeCell::get_mut`] accessor; only
86/// [`Graph::process_cycle`] (`&self`) and the read-only `&self` accessors
87/// (`read_output`, `read_input`, `node_count`, …) use the `unsafe`
88/// dereference, each annotated with a `# Safety` rationale.
89/// Internal node storage: either a plain node or a flushable sink node.
90///
91/// Wrapping nodes in this enum lets the engine track which nodes are
92/// flushable without `Any` downcast — sink nodes are registered via
93/// [`Graph::add_sink`] and stored as [`NodeSlot::Sink`]. Both variants
94/// implement [`AudioNode`] (delegating to the inner node), so the rest of the
95/// engine treats a `Vec<NodeSlot>` exactly like a `Vec<Box<dyn AudioNode>>`.
96enum NodeSlot {
97    /// A plain (non-flushable) node.
98    Plain(Box<dyn AudioNode>),
99    /// A flushable sink node (a `dyn SinkNode` = `AudioNode + Flushable`).
100    Sink(Box<dyn SinkNode>),
101}
102
103impl AudioNode for NodeSlot {
104    fn inputs(&self) -> &[audio_core_bsd::PortDescriptor] {
105        match self {
106            Self::Plain(n) => n.inputs(),
107            Self::Sink(n) => n.inputs(),
108        }
109    }
110    fn outputs(&self) -> &[audio_core_bsd::PortDescriptor] {
111        match self {
112            Self::Plain(n) => n.outputs(),
113            Self::Sink(n) => n.outputs(),
114        }
115    }
116    fn process(&mut self, ctx: &mut ProcessContext, i: &[AudioFrame], o: &mut [AudioFrame]) {
117        match self {
118            Self::Plain(n) => n.process(ctx, i, o),
119            Self::Sink(n) => n.process(ctx, i, o),
120        }
121    }
122}
123
124struct GraphScratch {
125    /// The nodes, indexed by [`NodeId`].
126    nodes: Vec<NodeSlot>,
127    /// Per-node, per-input-port scratch frame. Indexed `[node][port]`.
128    input_scratch: Vec<Vec<AudioFrame>>,
129    /// Per-node, per-output-port scratch frame. Indexed `[node][port]`.
130    output_scratch: Vec<Vec<AudioFrame>>,
131}
132
133/// A real-time-safe directed acyclic graph of audio nodes.
134///
135/// A `Graph` moves through three phases:
136///
137/// 1. **Build** — call [`Graph::add_node`] to register nodes and
138///    [`Graph::link`] to wire output ports to input ports.
139/// 2. **Compile** — call [`Graph::compile`] with a [`GraphConfig`]. This runs
140///    the topological sort, rejects cycles, and pre-allocates every scratch
141///    frame.
142/// 3. **Run** — call [`Graph::process_cycle`] once per audio cycle on the RT
143///    thread. Use [`Graph::feed`] to seed external inputs before a cycle and
144///    [`Graph::read_output`] / [`Graph::read_input`] to tap results after.
145///
146/// See the crate-level documentation for the real-time safety contract.
147pub struct Graph {
148    /// The directed edges (output-port -> input-port).
149    edges: Vec<Edge>,
150    /// Node execution order, filled by [`Graph::compile`].
151    execution_order: Vec<NodeId>,
152    /// Compile-time configuration.
153    config: GraphConfig,
154    /// Whether [`Graph::compile`] has run.
155    compiled: bool,
156    /// RT scratch + nodes, accessed via `&self` on the single RT thread.
157    ///
158    /// [`UnsafeCell`] + the single-RT-thread invariant makes `&self`-based
159    /// `process_cycle` sound; the cell is never shared across threads for
160    /// mutation. See `GraphScratch` for the soundness argument.
161    scratch: UnsafeCell<GraphScratch>,
162    /// Subscribers notified of topology events on the non-RT mutation path.
163    /// Absent entirely without the `topology` feature (no dependency on
164    /// `mpsc` or `TopologyEvent` in the default build).
165    #[cfg(feature = "topology")]
166    subscribers: Vec<std::sync::mpsc::Sender<TopologyEvent>>,
167}
168
169impl Graph {
170    /// Creates an empty graph.
171    #[must_use]
172    pub fn new() -> Self {
173        Self {
174            edges: Vec::new(),
175            execution_order: Vec::new(),
176            config: GraphConfig::new(0, 0, 0),
177            compiled: false,
178            scratch: UnsafeCell::new(GraphScratch {
179                nodes: Vec::new(),
180                input_scratch: Vec::new(),
181                output_scratch: Vec::new(),
182            }),
183            #[cfg(feature = "topology")]
184            subscribers: Vec::new(),
185        }
186    }
187
188    /// Adds a node to the graph and returns its stable [`NodeId`].
189    ///
190    /// The returned id equals the node's index in insertion order and never
191    /// changes for the lifetime of the graph. Scratch frames for the node's
192    /// ports are allocated later in [`Graph::compile`].
193    #[must_use]
194    pub fn add_node(&mut self, node: Box<dyn AudioNode>) -> NodeId {
195        let scratch = self.scratch.get_mut();
196        let id = scratch.nodes.len();
197        scratch.nodes.push(NodeSlot::Plain(node));
198        // Keep the scratch index aligned with the node id; the actual per-port
199        // frames are allocated in compile().
200        scratch.input_scratch.push(Vec::new());
201        scratch.output_scratch.push(Vec::new());
202        // Notify topology subscribers (non-RT; no-op without the feature).
203        #[cfg(feature = "topology")]
204        self.emit_node_added(id);
205        id
206    }
207
208    /// Adds a flushable sink node and returns its stable [`NodeId`].
209    ///
210    /// Identical to [`Graph::add_node`] but registers the node as a flushable
211    /// sink, so the engine can drain it between cycles via
212    /// [`Graph::flush_sinks`] / [`Graph::flush_sink`]. Use this for
213    /// [`RingSink`](crate::RingSink) (and any custom sink node implementing
214    /// [`SinkNode`]).
215    ///
216    /// A node added via `add_node` is **not** flushable — `flush_sink` on it
217    /// returns [`FlushError::NotFlushable`].
218    pub fn add_sink(&mut self, sink: Box<dyn SinkNode>) -> NodeId {
219        let scratch = self.scratch.get_mut();
220        let id = scratch.nodes.len();
221        scratch.nodes.push(NodeSlot::Sink(sink));
222        scratch.input_scratch.push(Vec::new());
223        scratch.output_scratch.push(Vec::new());
224        // Notify topology subscribers (non-RT; no-op without the feature).
225        #[cfg(feature = "topology")]
226        self.emit_node_added(id);
227        id
228    }
229
230    /// Links an output port to an input port, validating both endpoints and
231    /// their compatibility.
232    ///
233    /// The `from` port must be an **output** and the `to` port an **input**;
234    /// their channel counts and sample formats must match. The edge is stored
235    /// but the graph is **not** recompiled — call [`Graph::compile`] (or design
236    /// the full topology before compiling) before processing.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`GraphError::NodeNotFound`] if either node does not exist,
241    /// [`GraphError::PortNotFound`] if a port index is out of range,
242    /// [`GraphError::PortDirectionMismatch`] if the directions are wrong, or
243    /// [`GraphError::PortIncompatible`] if channel/format differ.
244    pub fn link(
245        &mut self,
246        from: (NodeId, PortIdx),
247        to: (NodeId, PortIdx),
248    ) -> Result<LinkId, GraphError> {
249        let (from_node, from_port) = from;
250        let (to_node, to_port) = to;
251
252        // Validate ports and directions without holding mutable borrows across
253        // the later edges.push().
254        let (from_desc, to_desc) = {
255            let scratch = self.scratch.get_mut();
256            let from_n = scratch
257                .nodes
258                .get(from_node)
259                .ok_or(GraphError::NodeNotFound(from_node))?;
260            let to_n = scratch
261                .nodes
262                .get(to_node)
263                .ok_or(GraphError::NodeNotFound(to_node))?;
264            let from_desc = from_n
265                .outputs()
266                .get(from_port)
267                .ok_or(GraphError::PortNotFound {
268                    node: from_node,
269                    port: from_port,
270                })?;
271            let to_desc = to_n.inputs().get(to_port).ok_or(GraphError::PortNotFound {
272                node: to_node,
273                port: to_port,
274            })?;
275            (*from_desc, *to_desc)
276        };
277
278        if from_desc.direction != PortDirection::Output || to_desc.direction != PortDirection::Input
279        {
280            return Err(GraphError::PortDirectionMismatch { from, to });
281        }
282        if from_desc.channels != to_desc.channels
283            || from_desc.sample_format != to_desc.sample_format
284        {
285            return Err(GraphError::PortIncompatible { from, to });
286        }
287
288        let link_id = self.edges.len();
289        self.edges.push(Edge { from, to });
290        // Notify topology subscribers (non-RT; no-op without the feature).
291        #[cfg(feature = "topology")]
292        self.emit_event(&TopologyEvent::LinkAdded(SnapshotEdge { from, to }));
293        Ok(link_id)
294    }
295
296    /// Compiles the graph: topologically sorts the nodes and pre-allocates every
297    /// scratch frame.
298    ///
299    /// This is the only place allocation is permitted. After `compile`
300    /// succeeds, [`Graph::process_cycle`] is guaranteed to be allocation-free.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`GraphError::AlreadyCompiled`] if called twice, or
305    /// [`GraphError::CycleDetected`] if the topology contains a cycle.
306    pub fn compile(&mut self, config: GraphConfig) -> Result<(), GraphError> {
307        if self.compiled {
308            return Err(GraphError::AlreadyCompiled);
309        }
310        let order = topological_sort(self.scratch.get_mut().nodes.len(), &self.edges)
311            .map_err(|remaining| GraphError::CycleDetected { nodes: remaining })?;
312        self.execution_order = order;
313        self.config = config;
314
315        // Pre-allocate every per-port scratch frame so process_cycle never
316        // allocates. This is the ONLY place allocation is permitted.
317        let scratch = self.scratch.get_mut();
318        scratch.input_scratch = Vec::with_capacity(scratch.nodes.len());
319        scratch.output_scratch = Vec::with_capacity(scratch.nodes.len());
320        for node in &scratch.nodes {
321            let in_slots: Vec<AudioFrame> = node
322                .inputs()
323                .iter()
324                .map(|_| {
325                    AudioFrame::silence(config.channels, config.num_frames, config.sample_rate)
326                })
327                .collect();
328            let out_slots: Vec<AudioFrame> = node
329                .outputs()
330                .iter()
331                .map(|_| {
332                    AudioFrame::silence(config.channels, config.num_frames, config.sample_rate)
333                })
334                .collect();
335            scratch.input_scratch.push(in_slots);
336            scratch.output_scratch.push(out_slots);
337        }
338        self.compiled = true;
339        // Notify topology subscribers that the graph is now compiled (non-RT).
340        #[cfg(feature = "topology")]
341        self.emit_event(&TopologyEvent::GraphCompiled);
342        Ok(())
343    }
344
345    /// Processes one audio cycle on the real-time thread.
346    ///
347    /// For each node in dependency order this copies connected upstream outputs
348    /// into the node's input scratch (or zeroes unconnected inputs), then
349    /// invokes the node's
350    /// [`process`](audio_core_bsd::AudioNode::process). The whole pass is bounded
351    /// and allocation-free: every slice is pre-sized by [`Graph::compile`] and
352    /// only bounded `for` loops / slice copies are used.
353    ///
354    /// # Safety (caller invariant)
355    ///
356    /// This method takes `&self` (not `&mut self`) so it can be called on a
357    /// [`Graph`] loaded from a shared handle such as `ArcSwap<Graph>`. The
358    /// interior mutability of the scratch cell is sound **only** because the
359    /// caller guarantees the real-time thread is single and exclusive — no
360    /// other `process_cycle`, `compile`, `feed`, or `read_*` may run
361    /// concurrently on the same [`Graph`]. In practice the control thread
362    /// builds and compiles a *new* `Graph` before swapping it in, so the old
363    /// instance being processed is never touched from another thread.
364    ///
365    /// # Real-time safety
366    ///
367    /// This method performs **no** allocation, locking, panicking, or system
368    /// call. The single `Err(NotCompiled)` return on the uncompiled path is a
369    /// stack-only branch (the variant carries no heap data); on the happy path
370    /// the method returns `Ok(())`.
371    ///
372    /// # Errors
373    ///
374    /// Returns [`GraphError::NotCompiled`] if [`Graph::compile`] has not been
375    /// called.
376    pub fn process_cycle(&self, ctx: &mut ProcessContext) -> Result<(), GraphError> {
377        if !self.compiled {
378            return Err(GraphError::NotCompiled);
379        }
380
381        // SAFETY: the caller guarantees no concurrent access to this Graph's
382        // scratch — see the method-level "# Safety" note. The mutable borrow
383        // below is exclusive on the single RT thread and never overlaps a
384        // `&mut self` build/compile call (the borrow checker forbids `&self`
385        // and `&mut self` from coexisting on the same Graph).
386        let GraphScratch {
387            nodes,
388            input_scratch,
389            output_scratch,
390        } = unsafe { &mut *self.scratch.get() };
391
392        for &n in &self.execution_order {
393            // (a) Fill this node's input slots from upstream outputs, or zero them.
394            let Some(in_slots) = input_scratch.get_mut(n) else {
395                continue;
396            };
397            for (pi, slot) in in_slots.iter_mut().enumerate() {
398                let mut sourced = false;
399                for edge in &self.edges {
400                    if edge.to == (n, pi) {
401                        let (src, src_port) = edge.from;
402                        if let Some(src_slots) = output_scratch.get(src) {
403                            if let Some(src_frame) = src_slots.get(src_port) {
404                                slot.channels = src_frame.channels;
405                                slot.sample_rate = src_frame.sample_rate;
406                                let copy_len = src_frame.samples.len().min(slot.samples.len());
407                                // Bounded, alloc-free copy over pre-sized slices.
408                                slot.samples[..copy_len]
409                                    .copy_from_slice(&src_frame.samples[..copy_len]);
410                            }
411                        }
412                        sourced = true;
413                        break;
414                    }
415                }
416                if !sourced {
417                    // No upstream edge: feed silence.
418                    for s in &mut slot.samples {
419                        *s = 0.0;
420                    }
421                }
422            }
423
424            // (b) Invoke the node over the filled input slots and its output scratch.
425            let Some(out_slots) = output_scratch.get_mut(n) else {
426                continue;
427            };
428            let Some(node) = nodes.get_mut(n) else {
429                continue;
430            };
431            node.process(ctx, in_slots.as_slice(), out_slots.as_mut_slice());
432        }
433
434        Ok(())
435    }
436
437    /// Seeds a node's output port from an external frame, before a cycle.
438    ///
439    /// Performs a bounded copy into the pre-sized scratch slot — no
440    /// reallocation. Used to inject audio into a source node's output before
441    /// calling [`Graph::process_cycle`]. Out-of-range node/port is a silent
442    /// no-op (never panics).
443    pub fn feed(&mut self, node: NodeId, port: PortIdx, src: &AudioFrame) {
444        if let Some(slots) = self.scratch.get_mut().output_scratch.get_mut(node) {
445            if let Some(dst) = slots.get_mut(port) {
446                dst.channels = src.channels;
447                dst.sample_rate = src.sample_rate;
448                let copy_len = src.samples.len().min(dst.samples.len());
449                dst.samples[..copy_len].copy_from_slice(&src.samples[..copy_len]);
450            }
451        }
452    }
453
454    /// Flushes every flushable sink node. Call BETWEEN cycles, never on the RT
455    /// thread (`flush` allocates).
456    ///
457    /// Iterates every node added via [`Graph::add_sink`] and pushes its stashed
458    /// frame across its ring. Returns the count of sinks flushed and the first
459    /// error encountered (if any); remaining sinks are still flushed after an
460    /// error so a single full ring does not block the rest.
461    ///
462    /// This is the engine-changes §4 (Option A) bridge that lets sonicbrew ship
463    /// outbound audio (graph → client) off the RT thread.
464    ///
465    /// # Real-time safety
466    ///
467    /// **OFF-RT ONLY.** `flush` clones the stashed frame and pushes it through
468    /// an `rtrb::Producer` (allocating). It must run in the between-cycle window
469    /// on the same thread that owns the graph, never inside `process_cycle`.
470    pub fn flush_sinks(&mut self) -> (usize, Option<FlushError>) {
471        let scratch = self.scratch.get_mut();
472        let mut count = 0;
473        let mut first_err = None;
474        for slot in &mut scratch.nodes {
475            if let NodeSlot::Sink(sink) = slot {
476                count += 1;
477                if let Err(e) = sink.flush() {
478                    if first_err.is_none() {
479                        first_err = Some(e);
480                    }
481                }
482            }
483        }
484        (count, first_err)
485    }
486
487    /// Flushes a single sink node by id (between cycles, off-RT).
488    ///
489    /// # Errors
490    ///
491    /// Returns [`FlushError::NotFlushable`] if the node was added via
492    /// [`Graph::add_node`] (not a sink), [`FlushError::NodeNotFound`] if the id
493    /// is out of range, or [`FlushError::RingFull`] if the sink's ring is full.
494    pub fn flush_sink(&mut self, node: NodeId) -> Result<(), FlushError> {
495        let scratch = self.scratch.get_mut();
496        match scratch.nodes.get_mut(node) {
497            Some(NodeSlot::Sink(sink)) => sink.flush(),
498            Some(NodeSlot::Plain(_)) => Err(FlushError::NotFlushable(node)),
499            None => Err(FlushError::NodeNotFound(node)),
500        }
501    }
502
503    /// Borrows a node's output frame after a cycle (tapping a node's output).
504    ///
505    /// Returns `None` if the node or port is out of range — never panics.
506    #[must_use]
507    pub fn read_output(&self, node: NodeId, port: PortIdx) -> Option<&AudioFrame> {
508        // SAFETY: read-only shared borrow; sound under the single-RT-thread
509        // invariant (never overlaps a mutable scratch access on this Graph).
510        let scratch = unsafe { &*self.scratch.get() };
511        scratch.output_scratch.get(node).and_then(|s| s.get(port))
512    }
513
514    /// Borrows the input frame that reached a node after a cycle.
515    ///
516    /// Sinks have zero outputs, so callers read a sink's consumed audio through
517    /// its input slot. Returns `None` if the node or port is out of range.
518    #[must_use]
519    pub fn read_input(&self, node: NodeId, port: PortIdx) -> Option<&AudioFrame> {
520        // SAFETY: read-only shared borrow; sound under the single-RT-thread
521        // invariant (never overlaps a mutable scratch access on this Graph).
522        let scratch = unsafe { &*self.scratch.get() };
523        scratch.input_scratch.get(node).and_then(|s| s.get(port))
524    }
525
526    /// Returns the number of nodes in the graph.
527    #[must_use]
528    pub fn node_count(&self) -> usize {
529        // SAFETY: read-only shared borrow; sound under the single-RT-thread
530        // invariant (never overlaps a mutable scratch access on this Graph).
531        unsafe { &*self.scratch.get() }.nodes.len()
532    }
533
534    /// Returns the number of links in the graph.
535    #[must_use]
536    pub fn link_count(&self) -> usize {
537        self.edges.len()
538    }
539
540    /// Returns `true` if the graph has been compiled.
541    #[must_use]
542    pub fn is_compiled(&self) -> bool {
543        self.compiled
544    }
545
546    /// Returns the compile-time [`GraphConfig`].
547    ///
548    /// Before [`Graph::compile`] this returns the default (zeroed) config.
549    #[must_use]
550    pub fn config(&self) -> GraphConfig {
551        self.config
552    }
553}
554
555impl Default for Graph {
556    fn default() -> Self {
557        Self::new()
558    }
559}
560
561// SAFETY: `Graph` contains an `UnsafeCell<GraphScratch>` (interior mutability for
562// the `&self` RT entry point), which makes it `!Sync` by default. However, a
563// `Graph` is sound to SHARE across threads (e.g. inside an `ArcSwap<Graph>` for
564// hot-reload) under the single-RT-thread invariant documented on
565// `GraphScratch`: only the dedicated RT thread ever mutates a given graph
566// instance's scratch (via `process_cycle`), and the control thread only ever
567// builds/compiles a NEW instance before publishing it. No two threads ever
568// access the SAME `Graph` instance's scratch concurrently. The `arc-swap`
569// handle provides the atomic pointer swap; this impl lets the `Arc<Graph>` be
570// `Send + Sync` so it can cross the control→RT thread boundary.
571unsafe impl Sync for Graph {}
572
573// =====================================================================================
574// `topology` feature: serializable snapshot model + non-RT mutation/observer API.
575//
576// Everything in this section is `#[cfg(feature = "topology")]`. The default
577// (0.1.0-compatible) build compiles none of it and stays serde-free.
578//
579// RT-safety (G3): `process_cycle` now takes `&self` (Phase-C prep), so the
580// topology snapshot read and `process_cycle` are *both* `&self` and the borrow
581// checker no longer forbids them from coexisting. RT/non-RT separation is
582// therefore a **runtime** contract — the single-RT-thread invariant on
583// `GraphScratch` — rather than a compile-time one. In the arc-swap hot-reload
584// model the control thread only ever builds/reads a *new* `Graph` while the RT
585// thread processes the *old* one, so they never touch the same instance.
586// =====================================================================================
587#[cfg(feature = "topology")]
588impl SnapshotSource for Graph {
589    fn topology_snapshot(&self) -> TopologySnapshot {
590        // SAFETY: read-only shared borrow; sound under the single-RT-thread
591        // invariant (never overlaps a mutable scratch access on this Graph).
592        let scratch = unsafe { &*self.scratch.get() };
593        let nodes = scratch
594            .nodes
595            .iter()
596            .enumerate()
597            .map(|(id, n)| NodeSnapshot {
598                id,
599                inputs: n
600                    .inputs()
601                    .iter()
602                    .map(|d| PortMeta::from_descriptor(*d))
603                    .collect(),
604                outputs: n
605                    .outputs()
606                    .iter()
607                    .map(|d| PortMeta::from_descriptor(*d))
608                    .collect(),
609            })
610            .collect();
611        let edges = self
612            .edges
613            .iter()
614            .map(|e| SnapshotEdge {
615                from: e.from,
616                to: e.to,
617            })
618            .collect();
619        TopologySnapshot { nodes, edges }
620    }
621}
622
623#[cfg(feature = "topology")]
624impl Graph {
625    /// Builds a NEW [`Graph`] from a [`TopologySnapshot`] plus a node factory.
626    ///
627    /// This is a **non-RT, control-thread** operation. For each
628    /// [`NodeSnapshot`] the `factory` is asked to supply a concrete
629    /// `Box<dyn AudioNode>` for that node's id; the snapshot's port metadata is
630    /// informational (the real node's ports, as reported by its
631    /// `inputs()` / `outputs()`, are what `link` validates against). Edges are
632    /// re-linked by remapping each snapshot [`NodeId`] to its new id in the
633    /// rebuilt graph.
634    ///
635    /// The returned graph is **not compiled** — the caller compiles it with
636    /// their own [`GraphConfig`].
637    ///
638    /// # Errors
639    ///
640    /// Returns [`GraphError::NodeNotFound`] if the factory returns `None` for a
641    /// node the snapshot requires, or if an edge references a node id the
642    /// factory did not supply. Returns the underlying [`GraphError`] from
643    /// [`Graph::link`] if a rebuilt edge fails port validation.
644    pub fn from_snapshot(
645        snapshot: &TopologySnapshot,
646        factory: &mut dyn FnMut(NodeId) -> Option<Box<dyn AudioNode>>,
647    ) -> Result<Graph, GraphError> {
648        let mut g = Graph::new();
649        // Snapshot ids may be non-contiguous (e.g. after RemoveNode), so remap
650        // each snapshot id to the new graph's contiguous id space.
651        let mut id_map = std::collections::HashMap::<NodeId, NodeId>::new();
652        for ns in &snapshot.nodes {
653            let node = factory(ns.id).ok_or(GraphError::NodeNotFound(ns.id))?;
654            let new_id = g.add_node(node);
655            id_map.insert(ns.id, new_id);
656        }
657        for se in &snapshot.edges {
658            let new_from = id_map
659                .get(&se.from.0)
660                .copied()
661                .ok_or(GraphError::NodeNotFound(se.from.0))?;
662            let new_to = id_map
663                .get(&se.to.0)
664                .copied()
665                .ok_or(GraphError::NodeNotFound(se.to.0))?;
666            g.link((new_from, se.from.1), (new_to, se.to.1))?;
667        }
668        Ok(g)
669    }
670
671    /// Subscribes to topology events via a `std::sync::mpsc` channel (non-RT).
672    ///
673    /// Returns a [`Receiver`](std::sync::mpsc::Receiver) that receives a
674    /// [`TopologyEvent`] whenever the graph mutates on the control thread (a
675    /// node is added/removed or a link is added/removed). The RT
676    /// [`Graph::process_cycle`](crate::Graph::process_cycle) path **never**
677    /// emits events.
678    ///
679    /// Senders whose receivers have been dropped are pruned automatically on
680    /// the next emission.
681    ///
682    /// # Real-time safety (G3)
683    ///
684    /// Subscription is a control-thread mutation of the subscriber set, so it
685    /// takes `&mut self` — an exclusive borrow. `process_cycle` is now `&self`
686    /// (Phase-C prep), so this `&mut self` is the stronger constraint: it is
687    /// statically impossible to hold the `&mut self` needed to subscribe *while*
688    /// a `&self` `process_cycle` borrow is live on the same binding. Combined
689    /// with the single-RT-thread invariant on `GraphScratch`, the RT path is
690    /// never disrupted by a concurrent subscription on the same `Graph`.
691    #[must_use]
692    pub fn subscribe_topology(&mut self) -> std::sync::mpsc::Receiver<TopologyEvent> {
693        let (tx, rx) = std::sync::mpsc::channel();
694        self.subscribers.push(tx);
695        rx
696    }
697
698    /// Emits a topology event to every live subscriber, pruning dead senders.
699    ///
700    /// Emits a topology event to every live subscriber, pruning dead senders.
701    ///
702    /// Non-RT: called only from the control-thread mutation path
703    /// (`add_node` / `link`).
704    fn emit_event(&mut self, event: &TopologyEvent) {
705        self.subscribers.retain(|tx| tx.send(event.clone()).is_ok());
706    }
707
708    /// Builds and emits a `NodeAdded` event for the node at `id`.
709    fn emit_node_added(&mut self, id: NodeId) {
710        let snapshot = {
711            let n = self
712                .scratch
713                .get_mut()
714                .nodes
715                .get(id)
716                .expect("emit_node_added called with a just-assigned NodeId");
717            NodeSnapshot {
718                id,
719                inputs: n
720                    .inputs()
721                    .iter()
722                    .map(|d| PortMeta::from_descriptor(*d))
723                    .collect(),
724                outputs: n
725                    .outputs()
726                    .iter()
727                    .map(|d| PortMeta::from_descriptor(*d))
728                    .collect(),
729            }
730        };
731        self.emit_event(&TopologyEvent::NodeAdded(snapshot));
732    }
733}
734
735// =====================================================================================
736// `distributed` feature: partition-hint derivation.
737//
738// The crate provides abstractions + hints only (no sockets/Raft/netmap). This
739// generator runs on the control thread and never touches the RT path.
740// =====================================================================================
741#[cfg(feature = "distributed")]
742impl Graph {
743    /// Derives partition hints from a set of `(node, port, remote)` boundary
744    /// declarations.
745    ///
746    /// Every node present in this graph is treated as a local node (the graph
747    /// *is* the local partition). Each declared port becomes a
748    /// [`BoundaryPort`](crate::BoundaryPort) with
749    /// [`PortKind::Network`](crate::PortKind::Network). The port's direction is
750    /// resolved from the node's own port descriptors: **output ports are
751    /// checked first**, so a port index valid on both sides is treated as an
752    /// output. A declaration whose port is out of range on both sides is
753    /// skipped (the method never panics).
754    ///
755    /// This is a **hint generator** — sonicbrew's session-store (M07) decides
756    /// the actual partitioning. Exactly one [`PartitionHint`] is returned,
757    /// describing this single local partition.
758    #[must_use]
759    pub fn partition_hints(
760        &self,
761        boundaries: &[(NodeId, PortIdx, RemoteNode)],
762    ) -> Vec<PartitionHint> {
763        // Every node in this graph runs locally.
764        let local_nodes: Vec<NodeId> = (0..self.node_count()).collect();
765        let boundary_ports: Vec<BoundaryPort> = boundaries
766            .iter()
767            .filter_map(|(node, port, remote)| {
768                let direction = self.boundary_port_direction(*node, *port)?;
769                Some(BoundaryPort {
770                    node: *node,
771                    port: *port,
772                    kind: PortKind::Network {
773                        remote: remote.clone(),
774                    },
775                    direction,
776                })
777            })
778            .collect();
779        vec![PartitionHint {
780            local_nodes,
781            boundary_ports,
782        }]
783    }
784
785    /// Resolves the [`PortDir`](crate::PortDir) of `(node, port)` by checking
786    /// the node's output ports first, then inputs. Returns `None` if the port
787    /// index is out of range on both sides.
788    fn boundary_port_direction(&self, node: NodeId, port: PortIdx) -> Option<PortDir> {
789        // SAFETY: read-only shared borrow; sound under the single-RT-thread
790        // invariant (never overlaps a mutable scratch access on this Graph).
791        let n = unsafe { &*self.scratch.get() }.nodes.get(node)?;
792        if port < n.outputs().len() {
793            Some(PortDir::Output)
794        } else if port < n.inputs().len() {
795            Some(PortDir::Input)
796        } else {
797            None
798        }
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805    use audio_core_bsd::{
806        AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
807    };
808
809    /// A minimal mono gain node for tests: 1 in, 1 out, scales by `gain`.
810    struct GainNode {
811        gain: f32,
812        in_port: [PortDescriptor; 1],
813        out_port: [PortDescriptor; 1],
814    }
815
816    impl GainNode {
817        fn new(gain: f32) -> Self {
818            Self {
819                gain,
820                in_port: [PortDescriptor::new(
821                    PortDirection::Input,
822                    1,
823                    SampleFormat::F32,
824                )],
825                out_port: [PortDescriptor::new(
826                    PortDirection::Output,
827                    1,
828                    SampleFormat::F32,
829                )],
830            }
831        }
832    }
833
834    impl AudioNode for GainNode {
835        fn inputs(&self) -> &[PortDescriptor] {
836            &self.in_port
837        }
838        fn outputs(&self) -> &[PortDescriptor] {
839            &self.out_port
840        }
841        fn process(
842            &mut self,
843            _ctx: &mut ProcessContext,
844            in_frames: &[AudioFrame],
845            out_frames: &mut [AudioFrame],
846        ) {
847            let Some(inp) = in_frames.first() else {
848                return;
849            };
850            let Some(out) = out_frames.get_mut(0) else {
851                return;
852            };
853            let n = inp.samples.len().min(out.samples.len());
854            for i in 0..n {
855                out.samples[i] = inp.samples[i] * self.gain;
856            }
857        }
858    }
859
860    /// A test source node: zero inputs, one output, and a no-op `process`.
861    ///
862    /// Because `process` never touches the output scratch, whatever
863    /// [`Graph::feed`] wrote into the source's output port survives the cycle
864    /// and flows downstream — exactly the behaviour a real source/gateway has.
865    struct SourceNode {
866        out_port: [PortDescriptor; 1],
867    }
868
869    impl SourceNode {
870        fn new(channels: u16) -> Self {
871            Self {
872                out_port: [PortDescriptor::new(
873                    PortDirection::Output,
874                    channels,
875                    SampleFormat::F32,
876                )],
877            }
878        }
879    }
880
881    impl AudioNode for SourceNode {
882        fn inputs(&self) -> &[PortDescriptor] {
883            &[]
884        }
885        fn outputs(&self) -> &[PortDescriptor] {
886            &self.out_port
887        }
888        fn process(
889            &mut self,
890            _ctx: &mut ProcessContext,
891            _in_frames: &[AudioFrame],
892            _out_frames: &mut [AudioFrame],
893        ) {
894            // Intentionally a no-op: the output scratch is seeded via Graph::feed.
895        }
896    }
897
898    /// Approximate float equality for assertions (uses `<`, never `==`).
899    fn approx_eq(a: f32, b: f32) -> bool {
900        (a - b).abs() < 1e-6
901    }
902
903    #[test]
904    fn empty_graph_compiles_and_runs() {
905        let mut g = Graph::new();
906        assert_eq!(g.node_count(), 0);
907        assert_eq!(g.link_count(), 0);
908        g.compile(GraphConfig::new(64, 48_000, 1)).unwrap();
909        let mut ctx = ProcessContext::new(64, 0, 48_000);
910        g.process_cycle(&mut ctx).unwrap();
911    }
912
913    #[test]
914    fn default_equals_new() {
915        let a = Graph::new();
916        let b = Graph::default();
917        assert_eq!(a.node_count(), b.node_count());
918        assert_eq!(a.link_count(), b.link_count());
919    }
920
921    #[test]
922    fn add_node_returns_sequential_ids() {
923        let mut g = Graph::new();
924        let a = g.add_node(Box::new(GainNode::new(1.0)));
925        let b = g.add_node(Box::new(GainNode::new(1.0)));
926        assert_eq!(a, 0);
927        assert_eq!(b, 1);
928        assert_eq!(g.node_count(), 2);
929    }
930
931    #[test]
932    fn link_valid_ports_succeeds() {
933        let mut g = Graph::new();
934        let s = g.add_node(Box::new(GainNode::new(1.0)));
935        let d = g.add_node(Box::new(GainNode::new(1.0)));
936        let link = g.link((s, 0), (d, 0)).unwrap();
937        assert_eq!(link, 0);
938        assert_eq!(g.link_count(), 1);
939    }
940
941    #[test]
942    fn link_unknown_node_errors() {
943        let mut g = Graph::new();
944        let s = g.add_node(Box::new(GainNode::new(1.0)));
945        assert_eq!(g.link((s, 0), (99, 0)), Err(GraphError::NodeNotFound(99)));
946        assert_eq!(g.link((99, 0), (s, 0)), Err(GraphError::NodeNotFound(99)));
947    }
948
949    #[test]
950    fn link_bad_port_errors() {
951        let mut g = Graph::new();
952        let s = g.add_node(Box::new(GainNode::new(1.0)));
953        let d = g.add_node(Box::new(GainNode::new(1.0)));
954        assert_eq!(
955            g.link((s, 5), (d, 0)),
956            Err(GraphError::PortNotFound { node: s, port: 5 })
957        );
958        assert_eq!(
959            g.link((s, 0), (d, 9)),
960            Err(GraphError::PortNotFound { node: d, port: 9 })
961        );
962    }
963
964    #[test]
965    fn process_before_compile_errors() {
966        let g = Graph::new();
967        let mut ctx = ProcessContext::new(64, 0, 48_000);
968        assert_eq!(g.process_cycle(&mut ctx), Err(GraphError::NotCompiled));
969    }
970
971    #[test]
972    fn compile_twice_errors() {
973        let mut g = Graph::new();
974        g.compile(GraphConfig::new(64, 48_000, 1)).unwrap();
975        assert_eq!(
976            g.compile(GraphConfig::new(64, 48_000, 1)),
977            Err(GraphError::AlreadyCompiled)
978        );
979    }
980
981    #[test]
982    fn cycle_is_rejected_at_compile() {
983        let mut g = Graph::new();
984        let a = g.add_node(Box::new(GainNode::new(1.0)));
985        let b = g.add_node(Box::new(GainNode::new(1.0)));
986        g.link((a, 0), (b, 0)).unwrap();
987        g.link((b, 0), (a, 0)).unwrap();
988        let err = g.compile(GraphConfig::new(64, 48_000, 1)).unwrap_err();
989        assert!(matches!(err, GraphError::CycleDetected { .. }));
990    }
991
992    #[test]
993    fn end_to_end_gain_chain() {
994        let mut g = Graph::new();
995        let src = g.add_node(Box::new(SourceNode::new(1))); // seeded via feed
996        let mid = g.add_node(Box::new(GainNode::new(0.5))); // half gain
997        g.link((src, 0), (mid, 0)).unwrap();
998        g.compile(GraphConfig::new(8, 48_000, 1)).unwrap();
999
1000        // Seed the source output with all-ones (survives process_cycle because
1001        // SourceNode::process is a no-op).
1002        g.feed(src, 0, &AudioFrame::from_planar(1, 48_000, vec![1.0; 8]));
1003        let mut ctx = ProcessContext::new(8, 0, 48_000);
1004        g.process_cycle(&mut ctx).unwrap();
1005
1006        // mid input should mirror src output (1.0); mid output should be 0.5.
1007        let mid_in = g.read_input(mid, 0).unwrap();
1008        assert!(mid_in.samples.iter().all(|&s| approx_eq(s, 1.0)));
1009        let mid_out = g.read_output(mid, 0).unwrap();
1010        assert!(mid_out.samples.iter().all(|&s| approx_eq(s, 0.5)));
1011    }
1012
1013    #[test]
1014    fn three_stage_chain_composes_gains() {
1015        let mut g = Graph::new();
1016        let src = g.add_node(Box::new(SourceNode::new(1)));
1017        let a = g.add_node(Box::new(GainNode::new(2.0)));
1018        let b = g.add_node(Box::new(GainNode::new(3.0)));
1019        let c = g.add_node(Box::new(GainNode::new(0.5)));
1020        g.link((src, 0), (a, 0)).unwrap();
1021        g.link((a, 0), (b, 0)).unwrap();
1022        g.link((b, 0), (c, 0)).unwrap();
1023        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
1024
1025        g.feed(src, 0, &AudioFrame::from_planar(1, 48_000, vec![1.0; 4]));
1026        let mut ctx = ProcessContext::new(4, 0, 48_000);
1027        g.process_cycle(&mut ctx).unwrap();
1028
1029        // 1.0 * 2.0 * 3.0 * 0.5 = 3.0
1030        let out = g.read_output(c, 0).unwrap();
1031        assert!(out.samples.iter().all(|&s| approx_eq(s, 3.0)));
1032    }
1033
1034    #[test]
1035    fn unconnected_input_is_silenced() {
1036        let mut g = Graph::new();
1037        let n = g.add_node(Box::new(GainNode::new(1.0)));
1038        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
1039        let mut ctx = ProcessContext::new(4, 0, 48_000);
1040        g.process_cycle(&mut ctx).unwrap();
1041        let inp = g.read_input(n, 0).unwrap();
1042        assert!(inp.samples.iter().all(|&s| approx_eq(s, 0.0)));
1043    }
1044
1045    #[test]
1046    fn read_out_of_range_returns_none() {
1047        let mut g = Graph::new();
1048        let n = g.add_node(Box::new(GainNode::new(1.0)));
1049        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
1050        assert!(g.read_output(99, 0).is_none());
1051        assert!(g.read_output(n, 99).is_none());
1052        assert!(g.read_input(99, 0).is_none());
1053        assert!(g.read_input(n, 99).is_none());
1054    }
1055
1056    #[test]
1057    fn feed_out_of_range_is_noop() {
1058        let mut g = Graph::new();
1059        let _n = g.add_node(Box::new(GainNode::new(1.0)));
1060        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
1061        // Must not panic.
1062        g.feed(99, 0, &AudioFrame::silence(1, 4, 48_000));
1063        g.feed(0, 99, &AudioFrame::silence(1, 4, 48_000));
1064    }
1065
1066    #[test]
1067    fn graphconfig_new_and_equality() {
1068        let a = GraphConfig::new(256, 48_000, 2);
1069        assert_eq!(a, GraphConfig::new(256, 48_000, 2));
1070        assert_ne!(a, GraphConfig::new(128, 48_000, 2));
1071        assert_eq!(a.num_frames, 256);
1072        assert_eq!(a.sample_rate, 48_000);
1073        assert_eq!(a.channels, 2);
1074    }
1075
1076    #[test]
1077    fn is_compiled_and_config_reflect_state() {
1078        let mut g = Graph::new();
1079        assert!(!g.is_compiled());
1080        g.compile(GraphConfig::new(4, 44_100, 2)).unwrap();
1081        assert!(g.is_compiled());
1082        assert_eq!(g.config(), GraphConfig::new(4, 44_100, 2));
1083    }
1084
1085    #[test]
1086    fn diamond_topology_processes_in_dependency_order() {
1087        // src(feed 1.0) -> {a(*2), b(*3)} ; a -> sink(*1).
1088        // b's output is unconnected (exercises the unconnected-output path).
1089        let mut g = Graph::new();
1090        let src = g.add_node(Box::new(SourceNode::new(1)));
1091        let a = g.add_node(Box::new(GainNode::new(2.0)));
1092        let b = g.add_node(Box::new(GainNode::new(3.0)));
1093        let sink = g.add_node(Box::new(GainNode::new(1.0)));
1094        g.link((src, 0), (a, 0)).unwrap();
1095        g.link((src, 0), (b, 0)).unwrap();
1096        g.link((a, 0), (sink, 0)).unwrap();
1097        let _ = b;
1098        g.compile(GraphConfig::new(4, 48_000, 1)).unwrap();
1099
1100        g.feed(src, 0, &AudioFrame::from_planar(1, 48_000, vec![1.0; 4]));
1101        let mut ctx = ProcessContext::new(4, 0, 48_000);
1102        g.process_cycle(&mut ctx).unwrap();
1103        let out = g.read_output(sink, 0).unwrap();
1104        // sink input == a output == 1.0 * 2.0 = 2.0; sink gain 1.0 -> 2.0.
1105        assert!(out.samples.iter().all(|&s| approx_eq(s, 2.0)));
1106    }
1107}