Skip to main content

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}