Skip to main content

audio_graph_bsd/
distributed.rs

1//! Distributed-prep abstractions — the `distributed` feature (ROADMAP §3.3).
2//!
3//! This entire module sits behind `#[cfg(feature = "distributed")]`. It
4//! provides the **data models, partition hints, and the network-link node
5//! contract** that sonicbrew's distributed runtime (M07 session-store, M09
6//! `net-rtp-aes67`) builds on. `audio-graph-bsd` itself implements no sockets,
7//! Raft consensus, or netmap plumbing — those concerns live in sonicbrew. The
8//! crate's dependency surface stays minimal: `serde` (via `topology`) for the
9//! serializable models and `arc-swap` (reserved for the Phase-C hot-reload
10//! atomic swap, unused here).
11//!
12//! # Real-time safety boundary
13//!
14//! [`GraphPartition`] simply wraps a [`Graph`](crate::Graph) and delegates
15//! [`GraphPartition::compile`] / [`GraphPartition::process_cycle`] to it, so
16//! the same RT-safety contract (alloc-free `process_cycle`) holds. A network
17//! link node implementing [`NetworkLinkNode`] MUST follow the rtrb bridge
18//! pattern documented on that trait and demonstrated by
19//! [`RingSource`](crate::RingSource) / [`RingSink`](crate::RingSink): the
20//! worker thread performs `RTP`/`AES67` recv/send and ring `push` (allocation
21//! allowed, non-RT); the RT `process` performs only wait-free ring `pop` and
22//! bounded copies.
23
24#![cfg(feature = "distributed")]
25
26use crate::error::GraphError;
27use crate::graph::{Graph, GraphConfig, NodeId, PortIdx};
28use crate::topology_pub::PortDir;
29use audio_core_bsd::{AudioNode, ProcessContext};
30
31/// Hint at which sonicbrew transport (M09) will carry a network link.
32///
33/// `audio-graph-bsd` never interprets this value; sonicbrew's
34/// `net-rtp-aes67` crate selects the concrete socket/pacing implementation.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36pub enum TransportHint {
37    /// Plain `RTP`/`UDP` transport.
38    Rtp,
39    /// `AES67`-compliant `RTP` (`PTP`-aligned, professional media).
40    Aes67,
41    /// A caller-defined transport chosen by sonicbrew.
42    Custom,
43}
44
45/// Serializable model of a node living on a remote host.
46///
47/// This describes **where** a node runs and **how** to reach it, not the node's
48/// DSP. The actual transport implementation is sonicbrew's responsibility (M09).
49#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50pub struct RemoteNode {
51    /// The remote node's identifier (as assigned by the originating graph).
52    pub node_id: NodeId,
53    /// Hostname or IP address of the remote host.
54    pub host: String,
55    /// Network port (e.g. `RTP` port) on the remote host.
56    pub port: u16,
57    /// Suggested transport for reaching this remote.
58    pub transport_hint: TransportHint,
59}
60
61/// Marks a port as a local scratch port or a network link to a [`RemoteNode`].
62#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub enum PortKind {
64    /// A local scratch port whose data never crosses the network.
65    Local,
66    /// A network link: this port's audio is exchanged with `remote`.
67    Network {
68        /// The remote node on the far side of the link.
69        remote: RemoteNode,
70    },
71}
72
73/// A port that sits on the local/remote boundary.
74///
75/// Combines the local `(node, port)` coordinates with the kind of port (local
76/// scratch vs. network link) and its [`PortDir`]. Boundary ports are the
77/// attachment points where audio crosses from a local partition onto the
78/// network (or arrives from it).
79#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80pub struct BoundaryPort {
81    /// The local node hosting the boundary port.
82    pub node: NodeId,
83    /// The port index on `node`.
84    pub port: PortIdx,
85    /// Whether this port is local scratch or a network link.
86    pub kind: PortKind,
87    /// Direction of the port (input or output), mirroring the node's own
88    /// [`PortDescriptor`](audio_core_bsd::PortDescriptor).
89    pub direction: PortDir,
90}
91
92/// Describes one local partition: which nodes run locally and which ports cross
93/// the network boundary.
94///
95/// This is a **hint**, not a mandate. sonicbrew's session-store (M07) decides
96/// the actual partitioning; `audio-graph-bsd` only derives candidate hints via
97/// [`Graph::partition_hints`](crate::Graph::partition_hints).
98#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
99pub struct PartitionHint {
100    /// Node ids that run in this local partition.
101    pub local_nodes: Vec<NodeId>,
102    /// Ports in this partition that cross the network boundary.
103    pub boundary_ports: Vec<BoundaryPort>,
104}
105
106/// A single local partition wrapping a [`Graph`] plus its boundary ports.
107///
108/// `GraphPartition` is a thin convenience wrapper: it owns the local sub-graph
109/// and the boundary declaration, and delegates [`GraphPartition::compile`] and
110/// [`GraphPartition::process_cycle`] straight to the inner [`Graph`]. The
111/// RT-safety contract of [`Graph::process_cycle`] is therefore unchanged.
112pub struct GraphPartition {
113    /// The local sub-graph.
114    graph: Graph,
115    /// Boundary ports for this partition.
116    boundary: Vec<BoundaryPort>,
117}
118
119impl GraphPartition {
120    /// Creates a new partition wrapping `graph` with the given `boundary`
121    /// ports.
122    #[must_use]
123    pub fn new(graph: Graph, boundary: Vec<BoundaryPort>) -> Self {
124        Self { graph, boundary }
125    }
126
127    /// Compiles the inner graph (delegates to [`Graph::compile`]).
128    ///
129    /// # Errors
130    ///
131    /// Returns [`GraphError::AlreadyCompiled`] if the graph was already
132    /// compiled, or [`GraphError::CycleDetected`] if the topology contains a
133    /// cycle.
134    pub fn compile(&mut self, config: GraphConfig) -> Result<(), GraphError> {
135        self.graph.compile(config)
136    }
137
138    /// Processes one audio cycle on the inner graph (delegates to
139    /// [`Graph::process_cycle`]).
140    ///
141    /// # Errors
142    ///
143    /// Returns [`GraphError::NotCompiled`] if the graph has not been compiled.
144    pub fn process_cycle(&mut self, ctx: &mut ProcessContext) -> Result<(), GraphError> {
145        self.graph.process_cycle(ctx)
146    }
147
148    /// Returns the boundary ports of this partition.
149    #[must_use]
150    pub fn boundary(&self) -> &[BoundaryPort] {
151        &self.boundary
152    }
153
154    /// Returns a shared reference to the inner graph.
155    #[must_use]
156    pub fn graph(&self) -> &Graph {
157        &self.graph
158    }
159
160    /// Returns a mutable reference to the inner graph.
161    pub fn graph_mut(&mut self) -> &mut Graph {
162        &mut self.graph
163    }
164}
165
166/// Contract for a network link node — a node that exchanges audio with a
167/// [`RemoteNode`] across the network.
168///
169/// A network link node **MUST** follow the rtrb bridge pattern demonstrated by
170/// [`RingSource`](crate::RingSource) and [`RingSink`](crate::RingSink):
171///
172/// - The **worker thread** performs the `RTP`/`AES67` recv/send and pushes
173///   received frames into an [`rtrb`] ring producer. Allocation and blocking
174///   I/O are allowed here — this path is **not** real-time.
175/// - The RT `process` implementation performs only a **wait-free** ring
176///   consumer `pop` (or a bounded copy into a pre-sized stash) — no allocation,
177///   lock, panic, or syscall.
178///
179/// sonicbrew's `net-rtp-aes67` crate (M09) is the canonical implementor.
180pub trait NetworkLinkNode: AudioNode {
181    /// Returns the remote node this link communicates with.
182    #[must_use]
183    fn remote(&self) -> &RemoteNode;
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use audio_core_bsd::{
190        AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
191    };
192
193    /// A minimal test node with one mono input and one mono output (f32). Used
194    /// to build a tiny graph for partition / delegation tests.
195    struct PassthroughNode {
196        gain: f32,
197        in_port: [PortDescriptor; 1],
198        out_port: [PortDescriptor; 1],
199    }
200
201    impl PassthroughNode {
202        fn new(gain: f32) -> Self {
203            Self {
204                gain,
205                in_port: [PortDescriptor::new(
206                    PortDirection::Input,
207                    1,
208                    SampleFormat::F32,
209                )],
210                out_port: [PortDescriptor::new(
211                    PortDirection::Output,
212                    1,
213                    SampleFormat::F32,
214                )],
215            }
216        }
217    }
218
219    impl AudioNode for PassthroughNode {
220        fn inputs(&self) -> &[PortDescriptor] {
221            &self.in_port
222        }
223        fn outputs(&self) -> &[PortDescriptor] {
224            &self.out_port
225        }
226        fn process(
227            &mut self,
228            _ctx: &mut ProcessContext,
229            in_frames: &[AudioFrame],
230            out_frames: &mut [AudioFrame],
231        ) {
232            let Some(inp) = in_frames.first() else {
233                return;
234            };
235            let Some(out) = out_frames.get_mut(0) else {
236                return;
237            };
238            let n = inp.samples.len().min(out.samples.len());
239            for i in 0..n {
240                out.samples[i] = inp.samples[i] * self.gain;
241            }
242        }
243    }
244
245    #[test]
246    fn remote_node_serde_roundtrip() {
247        let node = RemoteNode {
248            node_id: 7,
249            host: "192.168.1.42".to_string(),
250            port: 5004,
251            transport_hint: TransportHint::Rtp,
252        };
253        let bytes = bincode::serialize(&node).expect("serialize");
254        let back: RemoteNode = bincode::deserialize(&bytes).expect("deserialize");
255        assert_eq!(node, back);
256    }
257
258    #[test]
259    fn transport_hint_serde_roundtrip() {
260        for hint in [
261            TransportHint::Rtp,
262            TransportHint::Aes67,
263            TransportHint::Custom,
264        ] {
265            let bytes = bincode::serialize(&hint).expect("serialize");
266            let back: TransportHint = bincode::deserialize(&bytes).expect("deserialize");
267            assert_eq!(hint, back);
268        }
269    }
270
271    #[test]
272    fn port_kind_local_vs_network() {
273        let local = PortKind::Local;
274        let remote = RemoteNode {
275            node_id: 1,
276            host: "host".to_string(),
277            port: 10,
278            transport_hint: TransportHint::Aes67,
279        };
280        let net = PortKind::Network {
281            remote: remote.clone(),
282        };
283        assert_eq!(local, PortKind::Local);
284        let PortKind::Network { remote: r } = &net else {
285            panic!("expected Network");
286        };
287        assert_eq!(r, &remote);
288    }
289
290    #[test]
291    fn partition_hint_construction() {
292        let bp = BoundaryPort {
293            node: 0,
294            port: 0,
295            kind: PortKind::Network {
296                remote: RemoteNode {
297                    node_id: 9,
298                    host: "h".to_string(),
299                    port: 1,
300                    transport_hint: TransportHint::Custom,
301                },
302            },
303            direction: PortDir::Output,
304        };
305        let hint = PartitionHint {
306            local_nodes: vec![0, 1],
307            boundary_ports: vec![bp.clone()],
308        };
309        assert_eq!(hint.local_nodes, vec![0, 1]);
310        assert_eq!(hint.boundary_ports, vec![bp]);
311    }
312
313    #[test]
314    fn graph_partition_delegates_compile_and_process() {
315        let mut g = Graph::new();
316        let a = g.add_node(Box::new(PassthroughNode::new(1.0)));
317        let b = g.add_node(Box::new(PassthroughNode::new(1.0)));
318        g.link((a, 0), (b, 0)).unwrap();
319
320        let boundary = vec![BoundaryPort {
321            node: b,
322            port: 0,
323            kind: PortKind::Network {
324                remote: RemoteNode {
325                    node_id: 100,
326                    host: "remote".to_string(),
327                    port: 5004,
328                    transport_hint: TransportHint::Rtp,
329                },
330            },
331            direction: PortDir::Output,
332        }];
333        let mut part = GraphPartition::new(g, boundary);
334        // Delegate: compile + process_cycle must succeed and not panic.
335        part.compile(GraphConfig::new(8, 48_000, 1)).unwrap();
336        let mut ctx = ProcessContext::new(8, 0, 48_000);
337        part.process_cycle(&mut ctx).unwrap();
338        // Boundary accessor.
339        assert_eq!(part.boundary().len(), 1);
340        assert_eq!(part.boundary()[0].node, b);
341        // Inner graph accessors.
342        assert_eq!(part.graph().node_count(), 2);
343        assert_eq!(part.graph().link_count(), 1);
344    }
345
346    #[test]
347    fn partition_hints_returns_one_hint_with_boundary() {
348        let mut g = Graph::new();
349        let a = g.add_node(Box::new(PassthroughNode::new(1.0)));
350        // Declare a boundary on node a's port 0 (an output port).
351        let remote = RemoteNode {
352            node_id: 5,
353            host: "far".to_string(),
354            port: 6004,
355            transport_hint: TransportHint::Aes67,
356        };
357        let hints = g.partition_hints(&[(a, 0, remote.clone())]);
358        assert_eq!(hints.len(), 1);
359        let hint = &hints[0];
360        // Local set = all nodes in the graph.
361        assert_eq!(hint.local_nodes, vec![a]);
362        // One boundary port, Network, Output (port 0 is an output).
363        assert_eq!(hint.boundary_ports.len(), 1);
364        let bp = &hint.boundary_ports[0];
365        assert_eq!(bp.node, a);
366        assert_eq!(bp.port, 0);
367        assert_eq!(bp.direction, PortDir::Output);
368        let PortKind::Network { remote: r } = &bp.kind else {
369            panic!("expected Network");
370        };
371        assert_eq!(r, &remote);
372    }
373
374    #[test]
375    fn partition_hints_skips_out_of_range_port() {
376        let mut g = Graph::new();
377        let a = g.add_node(Box::new(PassthroughNode::new(1.0)));
378        // Port 99 does not exist on either side -> declaration is skipped, but
379        // the local set is still the full graph.
380        let remote = RemoteNode {
381            node_id: 5,
382            host: "far".to_string(),
383            port: 6004,
384            transport_hint: TransportHint::Rtp,
385        };
386        let hints = g.partition_hints(&[(a, 99, remote)]);
387        assert_eq!(hints.len(), 1);
388        assert_eq!(hints[0].local_nodes, vec![a]);
389        assert!(hints[0].boundary_ports.is_empty());
390    }
391}