sim-lib-audio-graph-core 0.1.0-rc.1

Pure Rust audio processor graph primitives.
Documentation
use crate::{
    AudioGraphNodeConfig, AudioGraphPatchDescriptor, ClockDomain, Graph, LatencyClass, Patch,
    PatchNode, PortDecl, PortDir, PortMedia, PortUri, PrepareConfig, ProcessBlock, Processor,
};

#[derive(Debug)]
struct GainNode {
    gain: f32,
    prepared: Option<PrepareConfig>,
}

impl GainNode {
    fn new(gain: f32) -> Self {
        Self {
            gain,
            prepared: None,
        }
    }
}

impl Processor for GainNode {
    fn prepare(&mut self, cfg: PrepareConfig) {
        self.prepared = Some(cfg);
    }

    fn reset(&mut self) {
        self.prepared = None;
    }

    fn process(&mut self, block: &mut ProcessBlock<'_>) {
        let frames = block.frames as usize;
        for (input, output) in block.in_audio.iter().zip(block.out_audio.iter_mut()) {
            for (source, target) in input.iter().zip(output.iter_mut()).take(frames) {
                *target = *source * self.gain;
            }
        }
    }
}

#[derive(Debug, Default)]
struct PassThroughNode;

impl Processor for PassThroughNode {
    fn prepare(&mut self, _cfg: PrepareConfig) {}

    fn reset(&mut self) {}

    fn process(&mut self, block: &mut ProcessBlock<'_>) {
        let frames = block.frames as usize;
        for (input, output) in block.in_audio.iter().zip(block.out_audio.iter_mut()) {
            output[..frames].copy_from_slice(&input[..frames]);
        }
    }
}

#[derive(Debug, Default)]
struct ControlSource;

impl Processor for ControlSource {
    fn prepare(&mut self, _cfg: PrepareConfig) {}

    fn reset(&mut self) {}

    fn process(&mut self, _block: &mut ProcessBlock<'_>) {}

    fn ports(&self, _in_channels: u16, _out_channels: u16) -> Vec<PortDecl> {
        vec![PortDecl::new("out", PortMedia::Control, PortDir::Out, 1)]
    }
}

#[derive(Debug, Default)]
struct ControlSink;

impl Processor for ControlSink {
    fn prepare(&mut self, _cfg: PrepareConfig) {}

    fn reset(&mut self) {}

    fn process(&mut self, _block: &mut ProcessBlock<'_>) {}

    fn ports(&self, _in_channels: u16, _out_channels: u16) -> Vec<PortDecl> {
        vec![PortDecl::new("in", PortMedia::Control, PortDir::In, 1)]
    }
}

#[test]
fn port_uri_parse_and_format_round_trip_exactly() {
    let text = "sim-node://graph/main/synth/out:0";
    let uri = text.parse::<PortUri>().expect("parse port URI");
    assert_eq!(uri.to_string(), text);
    assert_eq!(uri.node_id(), Some("synth"));
    assert_eq!(uri.node_port_name(), Some("out"));
}

#[test]
fn default_processor_descriptor_reports_placement_contract() {
    let mut graph = Graph::new();
    graph
        .add_node("gain", Box::new(GainNode::new(0.5)), 1, 1)
        .expect("add gain node");

    let descriptor = graph.node_descriptor("gain").expect("gain descriptor");

    assert_eq!(descriptor.clock_domain(), ClockDomain::Sample);
    assert_eq!(descriptor.latency_class(), LatencyClass::BlockLocal);
    assert!(descriptor.realtime_pin());
    let ports = descriptor.ports();
    assert_eq!(ports.len(), 2);
    assert_eq!(ports[0].rate_contract.clock_domain(), ClockDomain::Sample);
    assert_eq!(
        ports[0].rate_contract.latency_class(),
        LatencyClass::SampleExact
    );
}

#[test]
fn control_stream_ports_use_control_rate_contracts() {
    let mut graph = Graph::new();
    graph
        .add_node("source", Box::<ControlSource>::default(), 0, 1)
        .expect("add control source");

    let descriptor = graph.node_descriptor("source").expect("source descriptor");
    let ports = descriptor.ports();

    assert_eq!(ports.len(), 1);
    assert_eq!(ports[0].media, PortMedia::Control);
    assert_eq!(ports[0].rate_contract.clock_domain(), ClockDomain::Control);
    assert_eq!(
        ports[0].rate_contract.latency_class(),
        LatencyClass::Interactive
    );
}

#[test]
fn graph_rejects_mismatched_port_rate_contracts() {
    let mut graph = Graph::new();
    graph
        .add_node("source", Box::<ControlSource>::default(), 0, 1)
        .expect("add control source");
    graph
        .add_node("sink", Box::<PassThroughNode>::default(), 1, 0)
        .expect("add audio sink");

    let error = graph
        .connect(
            PortUri::node("main", "source", "out", 0).expect("source URI"),
            PortUri::node("main", "sink", "in", 0).expect("target URI"),
        )
        .expect_err("control to audio edge must be rejected");

    assert!(error.to_string().contains("incompatible port rate"));
}

#[test]
fn graph_connects_matching_control_stream_ports() {
    let mut graph = Graph::new();
    graph
        .add_node("source", Box::<ControlSource>::default(), 0, 1)
        .expect("add control source");
    graph
        .add_node("sink", Box::<ControlSink>::default(), 1, 0)
        .expect("add control sink");

    graph
        .connect(
            PortUri::node("main", "source", "out", 0).expect("source URI"),
            PortUri::node("main", "sink", "in", 0).expect("target URI"),
        )
        .expect("connect control stream");
}

#[test]
fn two_node_graph_runs_offline() {
    let mut graph = Graph::new();
    graph
        .add_node("gain", Box::new(GainNode::new(0.5)), 1, 1)
        .expect("add gain node");
    graph
        .add_node("pass", Box::<PassThroughNode>::default(), 1, 1)
        .expect("add pass-through node");
    graph
        .connect(
            PortUri::node("main", "gain", "out", 0).expect("source URI"),
            PortUri::node("main", "pass", "in", 0).expect("target URI"),
        )
        .expect("connect graph");

    graph.prepare(48_000, 4).expect("prepare graph");
    let output = graph
        .process_offline(&[vec![1.0, -0.5, 0.25, 0.0]], 4)
        .expect("process offline");

    assert_eq!(output, vec![vec![0.5, -0.25, 0.125, 0.0]]);
}

#[test]
fn graph_rejects_cycles() {
    let mut graph = Graph::new();
    graph
        .add_node("a", Box::new(PassThroughNode), 1, 1)
        .expect("add node a");
    graph
        .add_node("b", Box::new(PassThroughNode), 1, 1)
        .expect("add node b");
    graph
        .connect(
            PortUri::node("main", "a", "out", 0).expect("a out"),
            PortUri::node("main", "b", "in", 0).expect("b in"),
        )
        .expect("connect acyclic edge");

    let error = graph
        .connect(
            PortUri::node("main", "b", "out", 0).expect("b out"),
            PortUri::node("main", "a", "in", 0).expect("a in"),
        )
        .expect_err("cycle must be rejected");
    assert!(error.to_string().contains("cycle"));
}

#[test]
fn graph_patch_round_trips_as_expr() {
    let mut graph = Graph::new();
    graph
        .add_node("gain", Box::new(GainNode::new(0.25)), 1, 1)
        .expect("add gain node");
    graph
        .add_node("pass", Box::<PassThroughNode>::default(), 1, 1)
        .expect("add pass-through node");
    graph
        .connect(
            PortUri::node("main", "gain", "out", 0).expect("source URI"),
            PortUri::node("main", "pass", "in", 0).expect("target URI"),
        )
        .expect("connect graph");

    let patch = graph.to_patch();
    let rebuilt = Patch::from_expr(&patch.to_expr()).expect("decode patch expression");

    assert_eq!(rebuilt, patch);
}

#[test]
fn citizen_audio_graph_descriptors_round_trip_and_fail_closed() {
    let node = PatchNode {
        id: "gain".to_owned(),
        in_channels: 2,
        out_channels: 2,
    };
    let descriptor = AudioGraphNodeConfig::new(node.clone()).unwrap();
    assert_eq!(descriptor.node().unwrap(), node);

    let patch = Patch {
        nodes: vec![node],
        cables: Vec::new(),
    };
    let descriptor = AudioGraphPatchDescriptor::new(patch.clone()).unwrap();
    assert_eq!(descriptor.patch().unwrap(), patch);

    let err = AudioGraphNodeConfig::from_expr(sim_kernel::Expr::Map(vec![
        (
            sim_kernel::Expr::Symbol(sim_kernel::Symbol::qualified("audio-graph", "tag")),
            sim_kernel::Expr::Symbol(sim_kernel::Symbol::qualified("audio-graph", "node-config")),
        ),
        (
            sim_kernel::Expr::Symbol(sim_kernel::Symbol::qualified("audio-graph", "id")),
            sim_kernel::Expr::String(String::new()),
        ),
        (
            sim_kernel::Expr::Symbol(sim_kernel::Symbol::qualified("audio-graph", "in-channels")),
            sim_kernel::Expr::String("2".to_owned()),
        ),
        (
            sim_kernel::Expr::Symbol(sim_kernel::Symbol::qualified("audio-graph", "out-channels")),
            sim_kernel::Expr::String("2".to_owned()),
        ),
    ]))
    .unwrap_err();
    assert!(format!("{err}").contains("cannot be empty"));
}