Skip to main content

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}