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}