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}