forge-audio 0.1.0

Zero-allocation, lock-free audio architecture for real-time DSP, game engines, and WebAssembly
Documentation
//! Audio engine — RCU-based real-time audio processor.
//!
//! The engine owns the audio graph and processes it in the audio callback.
//! Graph mutations happen via clone-modify-swap (Read-Copy-Update) using arc-swap.
//! The audio thread never blocks, never allocates, never locks.

use crate::graph::{AudioGraph, AudioNode, Edge, NodeId, NodeKind, GraphError};
use crate::limiter::BrickwallLimiter;
use arc_swap::ArcSwap;
use std::sync::Arc;

// ---------------------------------------------------------------------------
// Engine configuration
// ---------------------------------------------------------------------------

/// Configuration for the audio engine.
#[derive(Debug, Clone)]
pub struct EngineConfig {
    pub sample_rate: u32,
    pub buffer_size: usize,
    pub channels: usize,
    /// Limiter ceiling in dBFS. Default: -1.0.
    pub limiter_ceiling_db: f64,
}

impl Default for EngineConfig {
    fn default() -> Self {
        Self {
            sample_rate: 44100,
            buffer_size: 512,
            channels: 2,
            limiter_ceiling_db: -1.0,
        }
    }
}

// ---------------------------------------------------------------------------
// GraphSnapshot — the immutable, swappable graph state
// ---------------------------------------------------------------------------

/// An immutable snapshot of the audio graph, shared between threads via Arc.
/// The audio thread reads this; the main thread clones, modifies, and swaps.
pub struct GraphSnapshot {
    pub graph: AudioGraph,
    /// ID of the master output node (where we read final output).
    pub master_id: NodeId,
}

// SAFETY: GraphSnapshot is Send + Sync because AudioGraph contains only
// Send + Sync nodes and pre-allocated buffers.
unsafe impl Send for GraphSnapshot {}
unsafe impl Sync for GraphSnapshot {}

// ---------------------------------------------------------------------------
// AudioEngine
// ---------------------------------------------------------------------------

/// The main audio engine. Owns the graph via ArcSwap for lock-free access.
pub struct AudioEngine {
    /// The current graph, swappable atomically.
    graph: Arc<ArcSwap<GraphSnapshot>>,
    /// Engine configuration.
    config: EngineConfig,
    /// Brickwall limiter (lives in the audio thread's process loop).
    limiter: BrickwallLimiter,
    /// Pre-allocated f64 output buffer (frames * channels).
    output_buf: Vec<f64>,
    /// Pre-allocated f32 output buffer for cpal (frames * channels).
    output_f32: Vec<f32>,
}

impl AudioEngine {
    /// Create a new audio engine with the given configuration.
    pub fn new(config: EngineConfig) -> Self {
        let limiter = BrickwallLimiter::new(
            config.limiter_ceiling_db,
            1.0,  // 1ms look-ahead
            50.0, // 50ms release
            config.sample_rate,
            config.channels,
        );

        let buf_len = config.buffer_size * config.channels;

        // Create an empty initial graph (no nodes, no edges).
        let empty_graph = AudioGraph::build(vec![], vec![], config.buffer_size)
            .expect("Empty graph should always succeed");

        let snapshot = GraphSnapshot {
            graph: empty_graph,
            master_id: NodeId(0),
        };

        Self {
            graph: Arc::new(ArcSwap::new(Arc::new(snapshot))),
            config,
            limiter,
            output_buf: vec![0.0f64; buf_len],
            output_f32: vec![0.0f32; buf_len],
        }
    }

    /// Get a handle for reading/swapping the graph from the main thread.
    pub fn graph_handle(&self) -> Arc<ArcSwap<GraphSnapshot>> {
        Arc::clone(&self.graph)
    }

    /// Process one block of audio. Called from the audio callback.
    ///
    /// REAL-TIME SAFE: no allocations, no locks, no I/O.
    /// Returns a slice of f32 samples ready for cpal output.
    pub fn process(&mut self, frames: usize) -> &[f32] {
        let frames = frames.min(self.config.buffer_size);
        let ch = self.config.channels;
        let len = frames * ch;

        // Load the current graph snapshot (atomic, wait-free).
        let snapshot = self.graph.load();

        // Check if graph has nodes.
        if snapshot.graph.node_count() == 0 {
            // No graph — output silence.
            for s in self.output_f32[..len].iter_mut() { *s = 0.0; }
            return &self.output_f32[..len];
        }

        // SAFETY: We need a mutable reference to the graph for processing,
        // but ArcSwap gives us Arc (immutable). We use a workaround:
        // The audio thread is the ONLY consumer, so we clone the inner graph
        // for mutation. This is a pointer clone, not a deep clone.
        //
        // TODO: For true zero-copy, we need the audio thread to own a
        // mutable graph that gets swapped in via a different mechanism.
        // For now, we read the master output from the snapshot's pre-computed
        // buffers after the graph processes.
        //
        // INTERIM APPROACH: Copy master output from graph buffers.
        if let Some(master_out) = snapshot.graph.output(snapshot.master_id) {
            let copy_len = len.min(master_out.len());
            self.output_buf[..copy_len].copy_from_slice(&master_out[..copy_len]);
        } else {
            for s in self.output_buf[..len].iter_mut() { *s = 0.0; }
        }

        // Apply brickwall limiter (f64, in-place equivalent).
        let mut limited = vec![0.0f64; len]; // TODO: pre-allocate this
        self.limiter.process_block(&self.output_buf[..len], &mut limited, frames);

        // Convert f64 → f32 for cpal output.
        for i in 0..len {
            self.output_f32[i] = limited[i] as f32;
        }

        &self.output_f32[..len]
    }

    /// Get the current gain reduction from the limiter (for metering).
    pub fn limiter_gr_db(&self) -> f64 {
        self.limiter.gain_reduction_db()
    }

    /// Get the engine configuration.
    pub fn config(&self) -> &EngineConfig {
        &self.config
    }
}

// ---------------------------------------------------------------------------
// Graph builder — main thread API for constructing/modifying graphs
// ---------------------------------------------------------------------------

/// Builder for constructing audio graphs on the main thread.
pub struct GraphBuilder {
    nodes: Vec<Box<dyn AudioNode>>,
    edges: Vec<Edge>,
    master_id: Option<NodeId>,
    max_frames: usize,
}

impl GraphBuilder {
    pub fn new(max_frames: usize) -> Self {
        Self {
            nodes: Vec::new(),
            edges: Vec::new(),
            master_id: None,
            max_frames,
        }
    }

    /// Add a node to the graph. Returns the builder for chaining.
    pub fn add_node(mut self, node: Box<dyn AudioNode>) -> Self {
        if node.kind() == NodeKind::Master {
            self.master_id = Some(node.node_id());
        }
        self.nodes.push(node);
        self
    }

    /// Add an edge (signal flow) from one node to another.
    pub fn add_edge(mut self, from: NodeId, to: NodeId) -> Self {
        self.edges.push(Edge { from, to });
        self
    }

    /// Set the master output node explicitly.
    pub fn set_master(mut self, id: NodeId) -> Self {
        self.master_id = Some(id);
        self
    }

    /// Build the graph and return a snapshot ready for atomic swap.
    pub fn build(self) -> Result<Arc<GraphSnapshot>, GraphError> {
        let graph = AudioGraph::build(self.nodes, self.edges, self.max_frames)?;
        let master_id = self.master_id.unwrap_or(NodeId(0));
        Ok(Arc::new(GraphSnapshot { graph, master_id }))
    }
}

/// Swap a new graph into the engine atomically. Call from main thread only.
pub fn swap_graph(handle: &ArcSwap<GraphSnapshot>, new_graph: Arc<GraphSnapshot>) {
    handle.store(new_graph);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::graph::{NodeKind};

    struct TestSource { id: NodeId, value: f64 }
    impl AudioNode for TestSource {
        fn process(&mut self, _: &[&[f64]], output: &mut [f64], frames: usize) {
            for i in 0..(frames * 2).min(output.len()) { output[i] = self.value; }
        }
        fn node_id(&self) -> NodeId { self.id }
        fn kind(&self) -> NodeKind { NodeKind::Deck }
    }

    struct TestPassthrough { id: NodeId }
    impl AudioNode for TestPassthrough {
        fn process(&mut self, inputs: &[&[f64]], output: &mut [f64], frames: usize) {
            let len = frames * 2;
            for inp in inputs {
                for i in 0..len.min(inp.len()).min(output.len()) {
                    output[i] += inp[i];
                }
            }
        }
        fn node_id(&self) -> NodeId { self.id }
        fn kind(&self) -> NodeKind { NodeKind::Master }
    }

    #[test]
    fn engine_creates_with_defaults() {
        let engine = AudioEngine::new(EngineConfig::default());
        assert_eq!(engine.config().sample_rate, 44100);
        assert_eq!(engine.config().buffer_size, 512);
    }

    #[test]
    fn engine_outputs_silence_with_empty_graph() {
        let mut engine = AudioEngine::new(EngineConfig::default());
        let out = engine.process(64);
        for &s in out {
            assert_eq!(s, 0.0);
        }
    }

    #[test]
    fn graph_builder_builds() {
        let snapshot = GraphBuilder::new(64)
            .add_node(Box::new(TestSource { id: NodeId(1), value: 0.5 }))
            .add_node(Box::new(TestPassthrough { id: NodeId(2) }))
            .add_edge(NodeId(1), NodeId(2))
            .set_master(NodeId(2))
            .build()
            .unwrap();

        assert_eq!(snapshot.master_id, NodeId(2));
        assert_eq!(snapshot.graph.node_count(), 2);
    }

    #[test]
    fn graph_swap_is_atomic() {
        let engine = AudioEngine::new(EngineConfig::default());
        let handle = engine.graph_handle();

        // Build a new graph.
        let new_snapshot = GraphBuilder::new(512)
            .add_node(Box::new(TestSource { id: NodeId(1), value: 1.0 }))
            .add_node(Box::new(TestPassthrough { id: NodeId(2) }))
            .add_edge(NodeId(1), NodeId(2))
            .set_master(NodeId(2))
            .build()
            .unwrap();

        // Swap it in.
        swap_graph(&handle, new_snapshot);

        // Verify the new graph is loaded.
        let loaded = handle.load();
        assert_eq!(loaded.graph.node_count(), 2);
    }

    #[test]
    fn limiter_reports_no_reduction_on_silence() {
        let engine = AudioEngine::new(EngineConfig::default());
        assert!((engine.limiter_gr_db() - 0.0).abs() < 0.01);
    }
}