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}