Skip to main content

forge_audio/
engine.rs

1//! Audio engine — RCU-based real-time audio processor.
2//!
3//! The engine owns the audio graph and processes it in the audio callback.
4//! Graph mutations happen via clone-modify-swap (Read-Copy-Update) using arc-swap.
5//! The audio thread never blocks, never allocates, never locks.
6
7use crate::graph::{AudioGraph, AudioNode, Edge, NodeId, NodeKind, GraphError};
8use crate::limiter::BrickwallLimiter;
9use arc_swap::ArcSwap;
10use std::sync::Arc;
11
12// ---------------------------------------------------------------------------
13// Engine configuration
14// ---------------------------------------------------------------------------
15
16/// Configuration for the audio engine.
17#[derive(Debug, Clone)]
18pub struct EngineConfig {
19    pub sample_rate: u32,
20    pub buffer_size: usize,
21    pub channels: usize,
22    /// Limiter ceiling in dBFS. Default: -1.0.
23    pub limiter_ceiling_db: f64,
24}
25
26impl Default for EngineConfig {
27    fn default() -> Self {
28        Self {
29            sample_rate: 44100,
30            buffer_size: 512,
31            channels: 2,
32            limiter_ceiling_db: -1.0,
33        }
34    }
35}
36
37// ---------------------------------------------------------------------------
38// GraphSnapshot — the immutable, swappable graph state
39// ---------------------------------------------------------------------------
40
41/// An immutable snapshot of the audio graph, shared between threads via Arc.
42/// The audio thread reads this; the main thread clones, modifies, and swaps.
43pub struct GraphSnapshot {
44    pub graph: AudioGraph,
45    /// ID of the master output node (where we read final output).
46    pub master_id: NodeId,
47}
48
49// SAFETY: GraphSnapshot is Send + Sync because AudioGraph contains only
50// Send + Sync nodes and pre-allocated buffers.
51unsafe impl Send for GraphSnapshot {}
52unsafe impl Sync for GraphSnapshot {}
53
54// ---------------------------------------------------------------------------
55// AudioEngine
56// ---------------------------------------------------------------------------
57
58/// The main audio engine. Owns the graph via ArcSwap for lock-free access.
59pub struct AudioEngine {
60    /// The current graph, swappable atomically.
61    graph: Arc<ArcSwap<GraphSnapshot>>,
62    /// Engine configuration.
63    config: EngineConfig,
64    /// Brickwall limiter (lives in the audio thread's process loop).
65    limiter: BrickwallLimiter,
66    /// Pre-allocated f64 output buffer (frames * channels).
67    output_buf: Vec<f64>,
68    /// Pre-allocated f32 output buffer for cpal (frames * channels).
69    output_f32: Vec<f32>,
70}
71
72impl AudioEngine {
73    /// Create a new audio engine with the given configuration.
74    pub fn new(config: EngineConfig) -> Self {
75        let limiter = BrickwallLimiter::new(
76            config.limiter_ceiling_db,
77            1.0,  // 1ms look-ahead
78            50.0, // 50ms release
79            config.sample_rate,
80            config.channels,
81        );
82
83        let buf_len = config.buffer_size * config.channels;
84
85        // Create an empty initial graph (no nodes, no edges).
86        let empty_graph = AudioGraph::build(vec![], vec![], config.buffer_size)
87            .expect("Empty graph should always succeed");
88
89        let snapshot = GraphSnapshot {
90            graph: empty_graph,
91            master_id: NodeId(0),
92        };
93
94        Self {
95            graph: Arc::new(ArcSwap::new(Arc::new(snapshot))),
96            config,
97            limiter,
98            output_buf: vec![0.0f64; buf_len],
99            output_f32: vec![0.0f32; buf_len],
100        }
101    }
102
103    /// Get a handle for reading/swapping the graph from the main thread.
104    pub fn graph_handle(&self) -> Arc<ArcSwap<GraphSnapshot>> {
105        Arc::clone(&self.graph)
106    }
107
108    /// Process one block of audio. Called from the audio callback.
109    ///
110    /// REAL-TIME SAFE: no allocations, no locks, no I/O.
111    /// Returns a slice of f32 samples ready for cpal output.
112    pub fn process(&mut self, frames: usize) -> &[f32] {
113        let frames = frames.min(self.config.buffer_size);
114        let ch = self.config.channels;
115        let len = frames * ch;
116
117        // Load the current graph snapshot (atomic, wait-free).
118        let snapshot = self.graph.load();
119
120        // Check if graph has nodes.
121        if snapshot.graph.node_count() == 0 {
122            // No graph — output silence.
123            for s in self.output_f32[..len].iter_mut() { *s = 0.0; }
124            return &self.output_f32[..len];
125        }
126
127        // SAFETY: We need a mutable reference to the graph for processing,
128        // but ArcSwap gives us Arc (immutable). We use a workaround:
129        // The audio thread is the ONLY consumer, so we clone the inner graph
130        // for mutation. This is a pointer clone, not a deep clone.
131        //
132        // TODO: For true zero-copy, we need the audio thread to own a
133        // mutable graph that gets swapped in via a different mechanism.
134        // For now, we read the master output from the snapshot's pre-computed
135        // buffers after the graph processes.
136        //
137        // INTERIM APPROACH: Copy master output from graph buffers.
138        if let Some(master_out) = snapshot.graph.output(snapshot.master_id) {
139            let copy_len = len.min(master_out.len());
140            self.output_buf[..copy_len].copy_from_slice(&master_out[..copy_len]);
141        } else {
142            for s in self.output_buf[..len].iter_mut() { *s = 0.0; }
143        }
144
145        // Apply brickwall limiter (f64, in-place equivalent).
146        let mut limited = vec![0.0f64; len]; // TODO: pre-allocate this
147        self.limiter.process_block(&self.output_buf[..len], &mut limited, frames);
148
149        // Convert f64 → f32 for cpal output.
150        for i in 0..len {
151            self.output_f32[i] = limited[i] as f32;
152        }
153
154        &self.output_f32[..len]
155    }
156
157    /// Get the current gain reduction from the limiter (for metering).
158    pub fn limiter_gr_db(&self) -> f64 {
159        self.limiter.gain_reduction_db()
160    }
161
162    /// Get the engine configuration.
163    pub fn config(&self) -> &EngineConfig {
164        &self.config
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Graph builder — main thread API for constructing/modifying graphs
170// ---------------------------------------------------------------------------
171
172/// Builder for constructing audio graphs on the main thread.
173pub struct GraphBuilder {
174    nodes: Vec<Box<dyn AudioNode>>,
175    edges: Vec<Edge>,
176    master_id: Option<NodeId>,
177    max_frames: usize,
178}
179
180impl GraphBuilder {
181    pub fn new(max_frames: usize) -> Self {
182        Self {
183            nodes: Vec::new(),
184            edges: Vec::new(),
185            master_id: None,
186            max_frames,
187        }
188    }
189
190    /// Add a node to the graph. Returns the builder for chaining.
191    pub fn add_node(mut self, node: Box<dyn AudioNode>) -> Self {
192        if node.kind() == NodeKind::Master {
193            self.master_id = Some(node.node_id());
194        }
195        self.nodes.push(node);
196        self
197    }
198
199    /// Add an edge (signal flow) from one node to another.
200    pub fn add_edge(mut self, from: NodeId, to: NodeId) -> Self {
201        self.edges.push(Edge { from, to });
202        self
203    }
204
205    /// Set the master output node explicitly.
206    pub fn set_master(mut self, id: NodeId) -> Self {
207        self.master_id = Some(id);
208        self
209    }
210
211    /// Build the graph and return a snapshot ready for atomic swap.
212    pub fn build(self) -> Result<Arc<GraphSnapshot>, GraphError> {
213        let graph = AudioGraph::build(self.nodes, self.edges, self.max_frames)?;
214        let master_id = self.master_id.unwrap_or(NodeId(0));
215        Ok(Arc::new(GraphSnapshot { graph, master_id }))
216    }
217}
218
219/// Swap a new graph into the engine atomically. Call from main thread only.
220pub fn swap_graph(handle: &ArcSwap<GraphSnapshot>, new_graph: Arc<GraphSnapshot>) {
221    handle.store(new_graph);
222}
223
224// ---------------------------------------------------------------------------
225// Tests
226// ---------------------------------------------------------------------------
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::graph::{NodeKind};
232
233    struct TestSource { id: NodeId, value: f64 }
234    impl AudioNode for TestSource {
235        fn process(&mut self, _: &[&[f64]], output: &mut [f64], frames: usize) {
236            for i in 0..(frames * 2).min(output.len()) { output[i] = self.value; }
237        }
238        fn node_id(&self) -> NodeId { self.id }
239        fn kind(&self) -> NodeKind { NodeKind::Deck }
240    }
241
242    struct TestPassthrough { id: NodeId }
243    impl AudioNode for TestPassthrough {
244        fn process(&mut self, inputs: &[&[f64]], output: &mut [f64], frames: usize) {
245            let len = frames * 2;
246            for inp in inputs {
247                for i in 0..len.min(inp.len()).min(output.len()) {
248                    output[i] += inp[i];
249                }
250            }
251        }
252        fn node_id(&self) -> NodeId { self.id }
253        fn kind(&self) -> NodeKind { NodeKind::Master }
254    }
255
256    #[test]
257    fn engine_creates_with_defaults() {
258        let engine = AudioEngine::new(EngineConfig::default());
259        assert_eq!(engine.config().sample_rate, 44100);
260        assert_eq!(engine.config().buffer_size, 512);
261    }
262
263    #[test]
264    fn engine_outputs_silence_with_empty_graph() {
265        let mut engine = AudioEngine::new(EngineConfig::default());
266        let out = engine.process(64);
267        for &s in out {
268            assert_eq!(s, 0.0);
269        }
270    }
271
272    #[test]
273    fn graph_builder_builds() {
274        let snapshot = GraphBuilder::new(64)
275            .add_node(Box::new(TestSource { id: NodeId(1), value: 0.5 }))
276            .add_node(Box::new(TestPassthrough { id: NodeId(2) }))
277            .add_edge(NodeId(1), NodeId(2))
278            .set_master(NodeId(2))
279            .build()
280            .unwrap();
281
282        assert_eq!(snapshot.master_id, NodeId(2));
283        assert_eq!(snapshot.graph.node_count(), 2);
284    }
285
286    #[test]
287    fn graph_swap_is_atomic() {
288        let engine = AudioEngine::new(EngineConfig::default());
289        let handle = engine.graph_handle();
290
291        // Build a new graph.
292        let new_snapshot = GraphBuilder::new(512)
293            .add_node(Box::new(TestSource { id: NodeId(1), value: 1.0 }))
294            .add_node(Box::new(TestPassthrough { id: NodeId(2) }))
295            .add_edge(NodeId(1), NodeId(2))
296            .set_master(NodeId(2))
297            .build()
298            .unwrap();
299
300        // Swap it in.
301        swap_graph(&handle, new_snapshot);
302
303        // Verify the new graph is loaded.
304        let loaded = handle.load();
305        assert_eq!(loaded.graph.node_count(), 2);
306    }
307
308    #[test]
309    fn limiter_reports_no_reduction_on_silence() {
310        let engine = AudioEngine::new(EngineConfig::default());
311        assert!((engine.limiter_gr_db() - 0.0).abs() < 0.01);
312    }
313}