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