audio_graph_bsd/topology_pub.rs
1//! Serializable topology model — the `topology` feature (ROADMAP §3.2).
2//!
3//! This entire module is behind `#[cfg(feature = "topology")]`. When the
4//! feature is disabled every type here vanishes and the crate stays
5//! serde-free, preserving the 0.1.0 minimal-dependency contract.
6//!
7//! # Why a serialization *layer*?
8//!
9//! `audio-graph-bsd` owns nodes as `Box<dyn AudioNode>` trait objects, which
10//! cannot be serialized. Instead of serializing node implementations, this
11//! module captures their **port metadata** (direction / channel count / sample
12//! format) in serializable mirror types. A [`TopologySnapshot`] is therefore a
13//! structural description of the graph — enough for the sonicbrew session-store
14//! (M07) to replicate topology over Raft and for a control thread to rebuild a
15//! runnable [`Graph`](crate::Graph) by pairing each [`NodeSnapshot`] with a
16//! concrete node factory (see [`Graph::from_snapshot`](crate::Graph::from_snapshot)).
17//!
18//! # Real-time safety (G3)
19//!
20//! Nothing in this module touches the RT path. [`SnapshotSource`] takes
21//! `&self`, and all mutation APIs are associated functions or run on a
22//! control/worker thread. The borrow checker guarantees that a
23//! [`TopologySnapshot`] edit cannot run concurrently with
24//! [`Graph::process_cycle`](crate::Graph::process_cycle) on the same `Graph`,
25//! because `process_cycle` requires `&mut self`.
26
27#![cfg(feature = "topology")]
28
29use crate::graph::{LinkId, NodeId, PortIdx};
30
31// =====================================================================================
32// Mirror types for audio-core-bsd port metadata.
33//
34// audio-core-bsd's PortDescriptor / PortDirection / SampleFormat carry NO serde
35// derives (they are a separate, minimal-dependency crate). We re-declare
36// serde-enabled mirrors here so a TopologySnapshot can be (de)serialized without
37// forcing serde onto audio-core-bsd. PortMeta provides lossless conversions in
38// both directions.
39// =====================================================================================
40
41/// Serializable mirror of [`audio_core_bsd::PortDirection`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
43pub enum PortDir {
44 /// The port consumes audio from an upstream node.
45 Input,
46 /// The port produces audio for a downstream node.
47 Output,
48}
49
50/// Serializable mirror of [`audio_core_bsd::SampleFormat`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
52pub enum SampleFmt {
53 /// 32-bit float (the default sample format).
54 F32,
55 /// 64-bit float.
56 F64,
57 /// 16-bit signed integer.
58 I16,
59 /// 32-bit signed integer.
60 I32,
61}
62
63/// Serializable mirror of [`audio_core_bsd::PortDescriptor`].
64///
65/// Stores the three fields needed to describe a port's contract: its
66/// direction, channel count, and sample format. Convert to/from a
67/// `PortDescriptor` with [`PortMeta::from_descriptor`] /
68/// [`PortMeta::to_descriptor`].
69#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
70pub struct PortMeta {
71 /// Whether this port is an input or output.
72 pub direction: PortDir,
73 /// Number of channels carried by the port.
74 pub channels: u16,
75 /// Numeric format of each sample.
76 pub sample_format: SampleFmt,
77}
78
79impl PortMeta {
80 /// Constructs a `PortMeta` from an audio-core-bsd
81 /// [`PortDescriptor`](audio_core_bsd::PortDescriptor).
82 ///
83 /// This is a lossless field-by-field mapping; the reverse conversion is
84 /// [`PortMeta::to_descriptor`].
85 #[must_use]
86 pub fn from_descriptor(d: audio_core_bsd::PortDescriptor) -> Self {
87 Self {
88 direction: match d.direction {
89 audio_core_bsd::PortDirection::Input => PortDir::Input,
90 audio_core_bsd::PortDirection::Output => PortDir::Output,
91 },
92 channels: d.channels,
93 sample_format: match d.sample_format {
94 audio_core_bsd::SampleFormat::F32 => SampleFmt::F32,
95 audio_core_bsd::SampleFormat::F64 => SampleFmt::F64,
96 audio_core_bsd::SampleFormat::I16 => SampleFmt::I16,
97 audio_core_bsd::SampleFormat::I32 => SampleFmt::I32,
98 },
99 }
100 }
101
102 /// Converts this `PortMeta` back into an audio-core-bsd
103 /// [`PortDescriptor`](audio_core_bsd::PortDescriptor).
104 #[must_use]
105 pub fn to_descriptor(self) -> audio_core_bsd::PortDescriptor {
106 audio_core_bsd::PortDescriptor::new(
107 match self.direction {
108 PortDir::Input => audio_core_bsd::PortDirection::Input,
109 PortDir::Output => audio_core_bsd::PortDirection::Output,
110 },
111 self.channels,
112 match self.sample_format {
113 SampleFmt::F32 => audio_core_bsd::SampleFormat::F32,
114 SampleFmt::F64 => audio_core_bsd::SampleFormat::F64,
115 SampleFmt::I16 => audio_core_bsd::SampleFormat::I16,
116 SampleFmt::I32 => audio_core_bsd::SampleFormat::I32,
117 },
118 )
119 }
120}
121
122// =====================================================================================
123// Snapshot types.
124// =====================================================================================
125
126/// A directed edge captured in a [`TopologySnapshot`].
127///
128/// Mirrors the internal graph edge: an output port feeding an input port. The
129/// `(NodeId, PortIdx)` pairs are kept as plain tuples so the snapshot
130/// serializes compactly and stays format-agnostic.
131#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
132pub struct SnapshotEdge {
133 /// Source `(node, output-port)`.
134 pub from: (NodeId, PortIdx),
135 /// Destination `(node, input-port)`.
136 pub to: (NodeId, PortIdx),
137}
138
139/// Structural snapshot of a single node: its id plus the port metadata of
140/// every input and output port.
141///
142/// The node's *implementation* (`Box<dyn AudioNode>`) is deliberately not
143/// captured — it cannot be serialized. A rebuild pairs this metadata with a
144/// concrete factory (see [`Graph::from_snapshot`](crate::Graph::from_snapshot)).
145#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
146pub struct NodeSnapshot {
147 /// Stable id of the node within the graph.
148 pub id: NodeId,
149 /// Metadata for every input port, in the order the node reports them.
150 pub inputs: Vec<PortMeta>,
151 /// Metadata for every output port, in the order the node reports them.
152 pub outputs: Vec<PortMeta>,
153}
154
155/// A serializable, metadata-only snapshot of an entire graph topology.
156///
157/// Contains the node list (with per-port metadata) and the edge list. This is
158/// the unit the sonicbrew session-store (M07) replicates over Raft and the unit
159/// a control thread rebuilds a [`Graph`](crate::Graph) from.
160///
161/// Because nodes are `Box<dyn AudioNode>` trait objects, the snapshot stores
162/// **port metadata, not node implementations**. Rebuilding a runnable graph
163/// requires a factory that supplies a concrete `AudioNode` per
164/// [`NodeId`](crate::NodeId).
165#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
166pub struct TopologySnapshot {
167 /// All nodes, indexed by their stable [`NodeId`].
168 pub nodes: Vec<NodeSnapshot>,
169 /// All directed edges (output-port -> input-port).
170 pub edges: Vec<SnapshotEdge>,
171}
172
173impl TopologySnapshot {
174 /// Creates an empty snapshot.
175 #[must_use]
176 pub fn new() -> Self {
177 Self::default()
178 }
179
180 /// Finds a node snapshot by id.
181 #[must_use]
182 pub fn node(&self, id: NodeId) -> Option<&NodeSnapshot> {
183 self.nodes.iter().find(|n| n.id == id)
184 }
185
186 /// Applies an incremental [`Mutation`] to this snapshot in place.
187 ///
188 /// This is a pure data edit over the snapshot's `nodes` / `edges` vectors —
189 /// it never touches a live [`Graph`](crate::Graph) and runs on a
190 /// control/worker thread (Non-RT). The rules are:
191 ///
192 /// - [`Mutation::AddNode`]: inserts the node, or replaces an existing node
193 /// with the same id.
194 /// - [`Mutation::RemoveNode`]: drops the node **and** every edge that
195 /// touches it (dangling edges are meaningless once the node is gone).
196 /// - [`Mutation::AddLink`]: appends the edge.
197 /// - [`Mutation::RemoveLink`]: removes the edge at the given positional
198 /// [`LinkId`] (edges are indexed in insertion order; removal shifts the
199 /// indices of subsequent edges, exactly like the live `Graph`).
200 pub fn apply(&mut self, mutation: &Mutation) {
201 match mutation {
202 Mutation::AddNode(ns) => {
203 if let Some(existing) = self.nodes.iter_mut().find(|n| n.id == ns.id) {
204 *existing = ns.clone();
205 } else {
206 self.nodes.push(ns.clone());
207 }
208 }
209 Mutation::RemoveNode(id) => {
210 self.nodes.retain(|n| n.id != *id);
211 // Drop every edge touching the removed node so the snapshot
212 // never carries dangling references.
213 self.edges.retain(|e| e.from.0 != *id && e.to.0 != *id);
214 }
215 Mutation::AddLink(se) => {
216 self.edges.push(*se);
217 }
218 Mutation::RemoveLink(link_id) => {
219 // LinkId is positional into `edges`; removal shifts later ids.
220 if *link_id < self.edges.len() {
221 self.edges.remove(*link_id);
222 }
223 }
224 }
225 }
226}
227
228// =====================================================================================
229// Events + Mutations.
230// =====================================================================================
231
232/// An observable change to a graph's topology.
233///
234/// Emitted on the non-RT mutation path (e.g. from `add_node` / `link`) and
235/// delivered to subscribers registered via
236/// [`Graph::subscribe_topology`](crate::Graph::subscribe_topology). The RT
237/// [`Graph::process_cycle`](crate::Graph::process_cycle) path **never** emits
238/// events.
239#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
240pub enum TopologyEvent {
241 /// A node was added; carries its structural snapshot.
242 NodeAdded(NodeSnapshot),
243 /// A node was removed; carries its id.
244 NodeRemoved(NodeId),
245 /// A link was added; carries the edge.
246 LinkAdded(SnapshotEdge),
247 /// A link was removed; carries its id.
248 LinkRemoved(LinkId),
249 /// The graph finished compiling (topological sort + scratch allocation).
250 GraphCompiled,
251 /// The graph was reset to an uncompiled state.
252 GraphReset,
253}
254
255/// A pure-data edit applicable to a [`TopologySnapshot`] via
256/// [`TopologySnapshot::apply`].
257///
258/// Mutations are themselves serializable, so a session store can log/replicate
259/// a stream of `Mutation`s and replay them onto a baseline snapshot.
260#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
261pub enum Mutation {
262 /// Add (or replace) the node with the given snapshot.
263 AddNode(NodeSnapshot),
264 /// Remove the node with the given id (and its incident edges).
265 RemoveNode(NodeId),
266 /// Add the given edge.
267 AddLink(SnapshotEdge),
268 /// Remove the edge at the given positional [`LinkId`].
269 RemoveLink(LinkId),
270}
271
272// =====================================================================================
273// Traits (observed topology — non-RT).
274// =====================================================================================
275
276/// A source of [`TopologySnapshot`] data.
277///
278/// Implemented by [`Graph`](crate::Graph) so external observers can read the
279/// current topology as serializable metadata. The method takes `&self`, so it
280/// is safe to call from a read-only context — but note that a `&self` borrow
281/// cannot coexist with the `&mut self` that
282/// [`Graph::process_cycle`](crate::Graph::process_cycle) requires, so the
283/// borrow checker prevents concurrent snapshot reads and RT processing on the
284/// same `Graph`.
285pub trait SnapshotSource {
286 /// Returns a serializable, metadata-only snapshot of the current topology.
287 #[must_use]
288 fn topology_snapshot(&self) -> TopologySnapshot;
289}
290
291/// An observer of [`TopologyEvent`]s.
292///
293/// Implement this to react to topology changes (e.g. to persist them, forward
294/// them over the network, or invalidate a cache). Events arrive on the
295/// non-RT mutation path only.
296pub trait TopologyObserver {
297 /// Called for each topology event emitted by an observed graph.
298 fn on_topology_event(&mut self, event: &TopologyEvent);
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::{Graph, GraphConfig};
305 use audio_core_bsd::{
306 AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
307 };
308
309 /// Test gain node: 1 mono in, 1 mono out, scales by `gain`.
310 struct GainNode {
311 gain: f32,
312 in_p: [PortDescriptor; 1],
313 out_p: [PortDescriptor; 1],
314 }
315 impl GainNode {
316 fn new(gain: f32) -> Self {
317 Self {
318 gain,
319 in_p: [PortDescriptor::input(1, SampleFormat::F32)],
320 out_p: [PortDescriptor::output(1, SampleFormat::F32)],
321 }
322 }
323 }
324 impl AudioNode for GainNode {
325 fn inputs(&self) -> &[PortDescriptor] {
326 &self.in_p
327 }
328 fn outputs(&self) -> &[PortDescriptor] {
329 &self.out_p
330 }
331 fn process(&mut self, _ctx: &mut ProcessContext, i: &[AudioFrame], o: &mut [AudioFrame]) {
332 let (Some(inp), Some(out)) = (i.first(), o.get_mut(0)) else {
333 return;
334 };
335 let n = inp.samples.len().min(out.samples.len());
336 for k in 0..n {
337 out.samples[k] = inp.samples[k] * self.gain;
338 }
339 }
340 }
341
342 /// Test source node: 0 in, 1 mono out, no-op process.
343 struct SourceNode {
344 out_p: [PortDescriptor; 1],
345 }
346 impl SourceNode {
347 fn new() -> Self {
348 Self {
349 out_p: [PortDescriptor::output(1, SampleFormat::F32)],
350 }
351 }
352 }
353 impl AudioNode for SourceNode {
354 fn inputs(&self) -> &[PortDescriptor] {
355 &[]
356 }
357 fn outputs(&self) -> &[PortDescriptor] {
358 &self.out_p
359 }
360 fn process(&mut self, _ctx: &mut ProcessContext, _i: &[AudioFrame], _o: &mut [AudioFrame]) {
361 }
362 }
363
364 fn mono_out() -> PortMeta {
365 PortMeta {
366 direction: PortDir::Output,
367 channels: 1,
368 sample_format: SampleFmt::F32,
369 }
370 }
371
372 fn mono_in() -> PortMeta {
373 PortMeta {
374 direction: PortDir::Input,
375 channels: 1,
376 sample_format: SampleFmt::F32,
377 }
378 }
379
380 // --- PortMeta roundtrip via PortDescriptor ---
381
382 #[test]
383 fn portmeta_from_descriptor_roundtrips_all_variants() {
384 for dir in [PortDirection::Input, PortDirection::Output] {
385 for (channels, fmt) in [
386 (1_u16, SampleFormat::F32),
387 (2_u16, SampleFormat::F64),
388 (6_u16, SampleFormat::I16),
389 (8_u16, SampleFormat::I32),
390 ] {
391 let d = PortDescriptor::new(dir, channels, fmt);
392 let meta = PortMeta::from_descriptor(d);
393 assert_eq!(meta.to_descriptor(), d, "roundtrip failed for {d:?}");
394 }
395 }
396 }
397
398 #[test]
399 fn snapshot_edge_is_copy_and_eq() {
400 let a = SnapshotEdge {
401 from: (0, 0),
402 to: (1, 0),
403 };
404 let b = a; // Copy
405 assert_eq!(a, b);
406 let c = SnapshotEdge {
407 from: (0, 0),
408 to: (2, 0),
409 };
410 assert_ne!(a, c);
411 }
412
413 // --- TopologySnapshot::apply ---
414
415 #[test]
416 fn apply_add_node_inserts_then_replaces() {
417 let mut snap = TopologySnapshot::new();
418 let ns = NodeSnapshot {
419 id: 3,
420 inputs: vec![mono_in()],
421 outputs: vec![mono_out()],
422 };
423 snap.apply(&Mutation::AddNode(ns.clone()));
424 assert_eq!(snap.node(3), Some(&ns));
425
426 // Same id with different metadata replaces in place.
427 let replaced = NodeSnapshot {
428 id: 3,
429 inputs: vec![],
430 outputs: vec![mono_out()],
431 };
432 snap.apply(&Mutation::AddNode(replaced.clone()));
433 assert_eq!(snap.nodes.len(), 1, "replace must not duplicate");
434 assert_eq!(snap.node(3), Some(&replaced));
435 }
436
437 #[test]
438 fn apply_add_and_remove_link_by_positional_id() {
439 let mut snap = TopologySnapshot::new();
440 let e0 = SnapshotEdge {
441 from: (0, 0),
442 to: (1, 0),
443 };
444 let e1 = SnapshotEdge {
445 from: (1, 0),
446 to: (2, 0),
447 };
448 snap.apply(&Mutation::AddLink(e0));
449 snap.apply(&Mutation::AddLink(e1));
450 assert_eq!(snap.edges, vec![e0, e1]);
451
452 // Remove link id 0 (positional). After removal, the former e1 shifts
453 // to index 0.
454 snap.apply(&Mutation::RemoveLink(0));
455 assert_eq!(snap.edges, vec![e1]);
456
457 // Out-of-range removal is a safe no-op.
458 snap.apply(&Mutation::RemoveLink(99));
459 assert_eq!(snap.edges, vec![e1]);
460 }
461
462 #[test]
463 fn apply_remove_node_drops_incident_edges() {
464 let mut snap = TopologySnapshot::new();
465 snap.nodes.push(NodeSnapshot {
466 id: 0,
467 inputs: vec![],
468 outputs: vec![mono_out()],
469 });
470 snap.nodes.push(NodeSnapshot {
471 id: 1,
472 inputs: vec![mono_in()],
473 outputs: vec![mono_out()],
474 });
475 snap.edges.push(SnapshotEdge {
476 from: (0, 0),
477 to: (1, 0),
478 });
479 // Remove node 0: the edge (0,0)->(1,0) touches it and must vanish.
480 snap.apply(&Mutation::RemoveNode(0));
481 assert!(snap.node(0).is_none());
482 assert!(snap.edges.is_empty());
483 }
484
485 // --- Graph::topology_snapshot() mirrors structure ---
486
487 #[test]
488 fn graph_topology_snapshot_matches_structure() {
489 let mut g = Graph::new();
490 let src = g.add_node(Box::new(SourceNode::new()));
491 let gain = g.add_node(Box::new(GainNode::new(0.5)));
492 let _link = g.link((src, 0), (gain, 0)).unwrap();
493
494 let snap = SnapshotSource::topology_snapshot(&g);
495 assert_eq!(snap.nodes.len(), 2);
496 assert_eq!(snap.edges.len(), 1);
497
498 // Source: 0 inputs, 1 output.
499 let src_ns = snap.node(src).unwrap();
500 assert!(src_ns.inputs.is_empty());
501 assert_eq!(src_ns.outputs.len(), 1);
502 assert_eq!(src_ns.outputs[0], mono_out());
503
504 // Gain: 1 input, 1 output.
505 let gain_ns = snap.node(gain).unwrap();
506 assert_eq!(gain_ns.inputs.len(), 1);
507 assert_eq!(gain_ns.inputs[0], mono_in());
508 assert_eq!(gain_ns.outputs.len(), 1);
509 assert_eq!(gain_ns.outputs[0], mono_out());
510
511 // Edge mirrors the link.
512 assert_eq!(
513 snap.edges[0],
514 SnapshotEdge {
515 from: (src, 0),
516 to: (gain, 0)
517 }
518 );
519 }
520
521 // --- Graph::from_snapshot rebuilds a compilable graph ---
522
523 #[test]
524 fn from_snapshot_rebuilds_compilable_graph() {
525 // Build a source graph, snapshot it, rebuild via factory, compile.
526 let mut orig = Graph::new();
527 let src = orig.add_node(Box::new(SourceNode::new()));
528 let gain = orig.add_node(Box::new(GainNode::new(0.25)));
529 orig.link((src, 0), (gain, 0)).unwrap();
530 let snap = SnapshotSource::topology_snapshot(&orig);
531
532 // Factory maps NodeId -> concrete node. NodeId order is preserved, so
533 // `src` is still 0 and `gain` is still 1 here; but map generically by
534 // snapshot id to be robust to remapping.
535 let mut rebuilt = Graph::from_snapshot(&snap, &mut |id| {
536 if id == src {
537 Some(Box::new(SourceNode::new()) as Box<dyn AudioNode>)
538 } else if id == gain {
539 Some(Box::new(GainNode::new(0.25)) as Box<dyn AudioNode>)
540 } else {
541 None
542 }
543 })
544 .expect("factory supplies all nodes");
545
546 assert_eq!(rebuilt.node_count(), 2);
547 assert_eq!(rebuilt.link_count(), 1);
548 rebuilt
549 .compile(GraphConfig::new(8, 48_000, 1))
550 .expect("rebuilt graph compiles");
551 }
552
553 #[test]
554 fn from_snapshot_errors_when_factory_returns_none() {
555 let snap = TopologySnapshot {
556 nodes: vec![NodeSnapshot {
557 id: 7,
558 inputs: vec![],
559 outputs: vec![mono_out()],
560 }],
561 edges: vec![],
562 };
563 // Factory refuses to supply node 7.
564 let result = Graph::from_snapshot(&snap, &mut |_| None);
565 assert!(result.is_err());
566 }
567}