aether_core/scheduler.rs
1//! Real-time audio scheduler.
2//!
3//! This is the hot path. It:
4//! 1. Drains bounded commands from the SPSC ring.
5//! 2. Executes the topologically sorted node list level by level.
6//! Nodes within the same BFS level are independent and run in parallel
7//! via Rayon's work-stealing thread pool.
8//! 3. Copies the output node's buffer to the DAC output.
9//!
10//! HARD RT RULES enforced here:
11//! - No allocation (Vec<NodeTask> is pre-allocated per level, bounded by MAX_NODES)
12//! - No locks
13//! - No I/O
14//! - No unbounded loops
15
16use ringbuf::traits::Consumer;
17
18use crate::{
19 arena::NodeId, command::Command, graph::DspGraph, node::DspNode, param::ParamBlock,
20 BUFFER_SIZE, MAX_COMMANDS_PER_TICK, MAX_INPUTS,
21};
22
23// ── Parallel dispatch helpers ─────────────────────────────────────────────────
24
25/// Per-node data bundle collected before parallel dispatch.
26///
27/// SAFETY INVARIANT: Within a single BFS level, every node writes to a distinct
28/// `BufferId` (guaranteed by the DAG structure — no two nodes in the same level
29/// share an output buffer). The `BufferPool` stores buffers in a flat `Vec`, so
30/// tasks writing to different `BufferId`s write to non-overlapping index ranges.
31/// This makes the concurrent writes safe despite using raw pointers.
32struct NodeTask {
33 output_buf_ptr: *mut [f32; BUFFER_SIZE],
34 params_ptr: *mut ParamBlock,
35 processor_ptr: *mut dyn DspNode,
36 inputs: [Option<*const [f32; BUFFER_SIZE]>; MAX_INPUTS],
37}
38
39/// SAFETY: Within a BFS level each task accesses disjoint memory:
40/// - distinct output buffer (different BufferId → different Vec index range)
41/// - distinct processor and params (each belongs to exactly one NodeRecord)
42///
43/// No two tasks in the same level share any pointed-to memory.
44unsafe impl Send for NodeTask {}
45unsafe impl Sync for NodeTask {}
46
47// ── Scheduler ─────────────────────────────────────────────────────────────────
48
49/// Real-time audio scheduler.
50///
51/// The scheduler owns the DSP graph and processes audio in fixed-size blocks
52/// (64 samples by default). It executes nodes in topologically sorted order,
53/// with nodes at the same BFS level running in parallel via Rayon.
54///
55/// # Real-Time Safety
56///
57/// - ✅ No allocation in audio thread
58/// - ✅ No locks in audio thread
59/// - ✅ Bounded execution time
60/// - ✅ Lock-free command processing via SPSC ring
61///
62/// # Example
63///
64/// ```
65/// use aether_core::scheduler::Scheduler;
66/// use aether_core::node::DspNode;
67/// use aether_core::param::ParamBlock;
68/// use aether_core::{BUFFER_SIZE, MAX_INPUTS};
69///
70/// // Create a simple oscillator node
71/// struct Oscillator {
72/// frequency: f32,
73/// phase: f32,
74/// }
75///
76/// impl DspNode for Oscillator {
77/// fn process(&mut self, _inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
78/// output: &mut [f32; BUFFER_SIZE], _params: &mut ParamBlock, sample_rate: f32) {
79/// let phase_inc = self.frequency / sample_rate;
80/// for sample in output.iter_mut() {
81/// *sample = (self.phase * std::f32::consts::TAU).sin() * 0.3;
82/// self.phase = (self.phase + phase_inc).fract();
83/// }
84/// }
85/// fn type_name(&self) -> &'static str { "Oscillator" }
86/// }
87///
88/// // Create scheduler and add node
89/// let mut sched = Scheduler::new(48_000.0);
90/// let osc = Box::new(Oscillator { frequency: 440.0, phase: 0.0 });
91/// let id = sched.graph.add_node(osc).unwrap();
92/// sched.graph.set_output_node(id);
93///
94/// // Process one audio block
95/// let mut output = vec![0.0f32; 128];
96/// sched.process_block_simple(&mut output);
97/// ```
98///
99/// # Performance
100///
101/// - Latency: 1.33ms @ 48kHz (64 samples)
102/// - Throughput: 1000+ nodes @ <100µs processing time
103/// - Memory: Pre-allocated arena + buffer pool
104pub struct Scheduler {
105 pub graph: DspGraph,
106 pub sample_rate: f32,
107 pub muted: bool,
108}
109
110impl Scheduler {
111 /// Creates a new scheduler with the given sample rate.
112 ///
113 /// # Arguments
114 ///
115 /// * `sample_rate` - Sample rate in Hz (typically 44100.0 or 48000.0)
116 ///
117 /// # Example
118 ///
119 /// ```
120 /// use aether_core::scheduler::Scheduler;
121 ///
122 /// let sched = Scheduler::new(48_000.0);
123 /// assert_eq!(sched.sample_rate, 48_000.0);
124 /// ```
125 pub fn new(sample_rate: f32) -> Self {
126 Self {
127 graph: DspGraph::new(),
128 sample_rate,
129 muted: false,
130 }
131 }
132
133 /// Processes one audio block with command draining.
134 ///
135 /// Call this from your audio thread (e.g., CPAL stream callback).
136 /// It drains up to `MAX_COMMANDS_PER_TICK` commands from the ring buffer,
137 /// applies them to the graph, then processes all nodes in topological order.
138 ///
139 /// # Arguments
140 ///
141 /// * `cmd_consumer` - SPSC consumer for control commands from UI/control thread
142 /// * `output` - Interleaved stereo output buffer (length = BUFFER_SIZE * 2)
143 ///
144 /// # Real-Time Safety
145 ///
146 /// This function is real-time safe:
147 /// - No allocations
148 /// - No locks (uses lock-free SPSC ring)
149 /// - Bounded execution time
150 /// - Parallel node execution within BFS levels
151 ///
152 /// # Example
153 ///
154 /// ```no_run
155 /// use aether_core::scheduler::Scheduler;
156 /// use aether_core::command::Command;
157 /// use ringbuf::{HeapRb, traits::Split};
158 ///
159 /// let mut sched = Scheduler::new(48_000.0);
160 /// let (mut producer, mut consumer) = HeapRb::<Command>::new(1024).split();
161 ///
162 /// // In audio thread callback:
163 /// let mut output = vec![0.0f32; 128]; // 64 frames * 2 channels
164 /// sched.process_block(&mut consumer, &mut output);
165 /// ```
166 ///
167 /// # See Also
168 ///
169 /// * [`process_block_simple`](Self::process_block_simple) - Simplified version without command ring
170 pub fn process_block<C>(&mut self, cmd_consumer: &mut C, output: &mut [f32])
171 where
172 C: Consumer<Item = Command>,
173 {
174 let mut processed = 0;
175 while processed < MAX_COMMANDS_PER_TICK {
176 match cmd_consumer.try_pop() {
177 Some(cmd) => {
178 self.apply_command(cmd);
179 processed += 1;
180 }
181 None => break,
182 }
183 }
184 self.process_graph(output);
185 }
186
187 /// Processes one audio block without command draining.
188 ///
189 /// Simplified version of [`process_block`](Self::process_block) that doesn't
190 /// drain commands from a ring buffer. Use this when the scheduler is shared
191 /// via `Arc<Mutex<>>` and the control thread mutates it directly.
192 ///
193 /// # Arguments
194 ///
195 /// * `output` - Interleaved stereo output buffer (length = BUFFER_SIZE * 2)
196 ///
197 /// # Real-Time Safety
198 ///
199 /// This function is real-time safe:
200 /// - No allocations
201 /// - No locks (assumes caller holds lock)
202 /// - Bounded execution time
203 ///
204 /// # Example
205 ///
206 /// ```
207 /// use aether_core::scheduler::Scheduler;
208 /// use aether_core::BUFFER_SIZE;
209 ///
210 /// let mut sched = Scheduler::new(48_000.0);
211 ///
212 /// // Process one block
213 /// let mut output = vec![0.0f32; BUFFER_SIZE * 2];
214 /// sched.process_block_simple(&mut output);
215 /// ```
216 ///
217 /// # See Also
218 ///
219 /// * [`process_block`](Self::process_block) - Version with command ring buffer
220 pub fn process_block_simple(&mut self, output: &mut [f32]) {
221 self.process_graph(output);
222 }
223
224 fn process_graph(&mut self, output: &mut [f32]) {
225 let sr = self.sample_rate;
226 let level_count = self.graph.levels.len();
227
228 for level_idx in 0..level_count {
229 let level_len = self.graph.levels[level_idx].len();
230
231 if level_len == 0 {
232 continue;
233 } else if level_len == 1 {
234 // Zero-overhead path: single node, no Rayon overhead.
235 let node_id = self.graph.levels[level_idx][0];
236 self.process_node(node_id, sr);
237 } else {
238 // Parallel path: collect raw pointers while holding &mut self,
239 // then dispatch DSP work in parallel via rayon::scope.
240 //
241 // SAFETY: Within a BFS level, every node writes to a distinct
242 // output buffer (disjoint BufferId). The BufferPool stores buffers
243 // in a flat Vec; tasks write to non-overlapping index ranges.
244 // Each processor and ParamBlock belongs to exactly one node.
245 let mut tasks: Vec<NodeTask> = Vec::with_capacity(level_len);
246
247 for i in 0..level_len {
248 let node_id = self.graph.levels[level_idx][i];
249 let mut input_ptrs: [Option<*const [f32; BUFFER_SIZE]>; MAX_INPUTS] =
250 [None; MAX_INPUTS];
251
252 if let Some(record) = self.graph.arena.get(node_id) {
253 for (slot, maybe_src) in record.inputs.iter().enumerate() {
254 if let Some(src_id) = maybe_src {
255 if let Some(src_record) = self.graph.arena.get(*src_id) {
256 input_ptrs[slot] =
257 Some(self.graph.buffers.get(src_record.output_buffer)
258 as *const [f32; BUFFER_SIZE]);
259 }
260 }
261 }
262 let record_mut = self.graph.arena.get_mut(node_id).unwrap();
263 let output_buf_ptr = self.graph.buffers.get_mut(record_mut.output_buffer)
264 as *mut [f32; BUFFER_SIZE];
265 let params_ptr = &mut record_mut.params as *mut ParamBlock;
266 let processor_ptr = &mut *record_mut.processor as *mut dyn DspNode;
267
268 tasks.push(NodeTask {
269 output_buf_ptr,
270 params_ptr,
271 processor_ptr,
272 inputs: input_ptrs,
273 });
274 }
275 }
276
277 // SAFETY: each element of `tasks` points to disjoint memory.
278 // We pass a raw pointer per task so each closure captures a
279 // distinct non-aliasing pointer.
280 #[cfg(feature = "parallel")]
281 {
282 rayon::scope(|s| {
283 for task in tasks.iter_mut() {
284 // Capture the raw pointer value (usize) to avoid the
285 // borrow checker complaining about &mut Vec element borrows.
286 let ptr = task as *mut NodeTask as usize;
287 s.spawn(move |_| {
288 // SAFETY: ptr is a valid, exclusively-owned NodeTask.
289 let t: &mut NodeTask = unsafe { &mut *(ptr as *mut NodeTask) };
290 let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] =
291 t.inputs.map(|p| p.map(|raw| unsafe { &*raw }));
292 unsafe {
293 (*t.processor_ptr).process(
294 &inputs,
295 &mut *t.output_buf_ptr,
296 &mut *t.params_ptr,
297 sr,
298 );
299 }
300 });
301 }
302 });
303 }
304
305 // Sequential fallback when parallel feature is disabled
306 #[cfg(not(feature = "parallel"))]
307 {
308 for task in tasks.iter_mut() {
309 let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] =
310 task.inputs.map(|p| p.map(|raw| unsafe { &*raw }));
311 unsafe {
312 (*task.processor_ptr).process(
313 &inputs,
314 &mut *task.output_buf_ptr,
315 &mut *task.params_ptr,
316 sr,
317 );
318 }
319 }
320 }
321 }
322 }
323
324 // Copy output node buffer to DAC
325 if self.muted {
326 output.fill(0.0);
327 return;
328 }
329 if let Some(out_id) = self.graph.output_node {
330 if let Some(record) = self.graph.arena.get(out_id) {
331 let buf = self.graph.buffers.get(record.output_buffer);
332 let frames = output.len() / 2;
333 for i in 0..frames.min(BUFFER_SIZE) {
334 output[i * 2] = buf[i];
335 output[i * 2 + 1] = buf[i];
336 }
337 }
338 } else {
339 // INVARIANT: empty graph → silence.
340 output.fill(0.0);
341 }
342 }
343
344 /// Process a single node on the calling thread.
345 fn process_node(&mut self, node_id: NodeId, sample_rate: f32) {
346 let mut input_ptrs: [Option<*const [f32; BUFFER_SIZE]>; MAX_INPUTS] = [None; MAX_INPUTS];
347
348 if let Some(record) = self.graph.arena.get(node_id) {
349 for (slot, maybe_src) in record.inputs.iter().enumerate() {
350 if let Some(src_id) = maybe_src {
351 if let Some(src_record) = self.graph.arena.get(*src_id) {
352 input_ptrs[slot] = Some(self.graph.buffers.get(src_record.output_buffer)
353 as *const [f32; BUFFER_SIZE]);
354 }
355 }
356 }
357 } else {
358 return;
359 }
360
361 let (output_buf_id, params_ptr, processor_ptr) = {
362 let record = self.graph.arena.get_mut(node_id).unwrap();
363 (
364 record.output_buffer,
365 &mut record.params as *mut ParamBlock,
366 &mut *record.processor as *mut dyn crate::node::DspNode,
367 )
368 };
369
370 let output_buf = self.graph.buffers.get_mut(output_buf_id);
371 let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] =
372 input_ptrs.map(|p| p.map(|ptr| unsafe { &*ptr }));
373
374 unsafe {
375 (*processor_ptr).process(&inputs, output_buf, &mut *params_ptr, sample_rate);
376 }
377 }
378
379 fn apply_command(&mut self, cmd: Command) {
380 match cmd {
381 Command::AddNode { id } => {
382 let _ = id;
383 }
384 Command::RemoveNode { id } => {
385 self.graph.remove_node(id);
386 }
387 Command::Connect { src, dst, slot } => {
388 self.graph.connect(src, dst, slot);
389 }
390 Command::Disconnect { dst, slot } => {
391 self.graph.disconnect(dst, slot);
392 }
393 Command::UpdateParam {
394 node,
395 param_index,
396 new_param,
397 } => {
398 if let Some(record) = self.graph.arena.get_mut(node) {
399 if param_index < record.params.count {
400 record.params.params[param_index] = new_param;
401 }
402 }
403 }
404 Command::SetOutputNode { id } => {
405 self.graph.set_output_node(id);
406 }
407 Command::SetMute { muted } => {
408 self.muted = muted;
409 }
410 Command::ClearGraph => {
411 let ids: Vec<_> = self.graph.execution_order.clone();
412 for id in ids {
413 self.graph.remove_node(id);
414 }
415 self.graph.output_node = None;
416 }
417 }
418 }
419
420 /// Reference sequential implementation for testing.
421 /// Processes nodes in flat execution_order without parallelism.
422 #[cfg(test)]
423 fn process_graph_sequential(&mut self, output: &mut [f32]) {
424 let sr = self.sample_rate;
425
426 // Collect execution order into a local Vec to avoid borrow conflict
427 // between the immutable borrow of execution_order and the mutable
428 // borrow inside process_node.
429 let order: Vec<NodeId> = self.graph.execution_order.clone();
430 for &node_id in &order {
431 self.process_node(node_id, sr);
432 }
433
434 // Copy output node buffer to DAC
435 if self.muted {
436 output.fill(0.0);
437 return;
438 }
439 if let Some(out_id) = self.graph.output_node {
440 if let Some(record) = self.graph.arena.get(out_id) {
441 let buf = self.graph.buffers.get(record.output_buffer);
442 let frames = output.len() / 2;
443 for i in 0..frames.min(BUFFER_SIZE) {
444 output[i * 2] = buf[i];
445 output[i * 2 + 1] = buf[i];
446 }
447 }
448 } else {
449 output.fill(0.0);
450 }
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::node::DspNode;
458 use proptest::prelude::*;
459
460 /// Minimal deterministic test node for property testing.
461 /// Sums all inputs and multiplies by a fixed gain.
462 struct TestNode {
463 gain: f32,
464 }
465
466 impl TestNode {
467 fn new(gain: f32) -> Self {
468 Self { gain }
469 }
470 }
471
472 impl DspNode for TestNode {
473 fn process(
474 &mut self,
475 inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
476 output: &mut [f32; BUFFER_SIZE],
477 _params: &mut ParamBlock,
478 _sample_rate: f32,
479 ) {
480 output.fill(0.0);
481 for input_opt in inputs.iter() {
482 if let Some(input) = input_opt {
483 for i in 0..BUFFER_SIZE {
484 output[i] += input[i] * self.gain;
485 }
486 }
487 }
488 }
489
490 fn type_name(&self) -> &'static str {
491 "TestNode"
492 }
493 }
494
495 // Property 1
496 proptest! {
497 /// **Validates: Requirements 1.1, 1.4**
498 ///
499 /// Feature: aether-engine-upgrades, Property 1: parallel execution is output-equivalent
500 ///
501 /// Property 1: Parallel execution is output-equivalent to sequential execution.
502 ///
503 /// For any valid DSP patch (any combination of nodes and edges forming a valid DAG),
504 /// processing a block with the parallel Rayon scheduler SHALL produce a bit-identical
505 /// output buffer to processing the same block with the original sequential scheduler,
506 /// given the same initial node state and the same input.
507 #[test]
508 fn prop_parallel_equiv_sequential(
509 num_nodes in 1usize..=20,
510 edges in prop::collection::vec((0usize..20, 0usize..20, 0usize..MAX_INPUTS), 0..50),
511 seed in any::<u64>(),
512 ) {
513 // Create two identical schedulers
514 let mut scheduler_parallel = Scheduler::new(48000.0);
515 let mut scheduler_sequential = Scheduler::new(48000.0);
516
517 let mut node_ids = Vec::new();
518
519 // Add nodes to both schedulers with deterministic gains based on seed
520 for i in 0..num_nodes {
521 let gain = ((seed.wrapping_add(i as u64) % 100) as f32) / 100.0;
522
523 let id1 = scheduler_parallel.graph.add_node(Box::new(TestNode::new(gain)));
524 let id2 = scheduler_sequential.graph.add_node(Box::new(TestNode::new(gain)));
525
526 if let (Some(id1), Some(id2)) = (id1, id2) {
527 // Verify both schedulers assigned the same NodeId
528 prop_assert_eq!(id1.index, id2.index);
529 prop_assert_eq!(id1.generation, id2.generation);
530 node_ids.push(id1);
531 }
532 }
533
534 // Add edges to both schedulers (filter to maintain DAG invariant: src < dst)
535 for (src_idx, dst_idx, slot) in edges {
536 if src_idx < num_nodes && dst_idx < num_nodes && src_idx < dst_idx {
537 let src = node_ids[src_idx];
538 let dst = node_ids[dst_idx];
539
540 scheduler_parallel.graph.connect(src, dst, slot);
541 scheduler_sequential.graph.connect(src, dst, slot);
542 }
543 }
544
545 // Set output node to the last node if we have any nodes
546 if !node_ids.is_empty() {
547 let output_node = node_ids[num_nodes - 1];
548 scheduler_parallel.graph.set_output_node(output_node);
549 scheduler_sequential.graph.set_output_node(output_node);
550 }
551
552 // Prepare output buffers (stereo, 64 frames = 128 samples)
553 let mut output_parallel = vec![0.0f32; BUFFER_SIZE * 2];
554 let mut output_sequential = vec![0.0f32; BUFFER_SIZE * 2];
555
556 // Process one block with both schedulers
557 scheduler_parallel.process_graph(&mut output_parallel);
558 scheduler_sequential.process_graph_sequential(&mut output_sequential);
559
560 // Assert bit-identical output
561 for (i, (&p, &s)) in output_parallel.iter().zip(output_sequential.iter()).enumerate() {
562 prop_assert!(
563 p == s || (p.is_nan() && s.is_nan()),
564 "Output mismatch at sample {}: parallel={}, sequential={}",
565 i, p, s
566 );
567 }
568 }
569 }
570}