elemaudio_rs/
graph.rs

1//! Minimal Rust-native graph DSL for Elementary-style node composition.
2//!
3//! This module provides the core graph types (`Graph`, `Node`, `MountedGraph`,
4//! etc.). For node composition helpers, see the `authoring` module.
5
6use crate::{Instruction, InstructionBatch, NodeId};
7
8/// Error returned by [`Graph::mount`] and [`Graph::mount_with_id_counter`].
9#[derive(Debug, Clone)]
10pub enum MountError {
11    /// Two nodes in the graph share the same `key` prop value.
12    DuplicateKey(String),
13}
14
15impl std::fmt::Display for MountError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            Self::DuplicateKey(key) => write!(f, "duplicate mounted node key: {key}"),
19        }
20    }
21}
22
23impl std::error::Error for MountError {}
24
25/// A multichannel graph is a set of root nodes, one per output channel.
26#[derive(Debug, Clone, Default)]
27pub struct Graph {
28    roots: Vec<Node>,
29}
30
31/// Input accepted by `Graph::render(...)`.
32#[derive(Debug, Clone)]
33pub enum GraphRoots {
34    /// A single root node.
35    Single(Node),
36    /// Multiple root nodes, one per output channel.
37    Many(Vec<Node>),
38}
39
40impl From<Node> for GraphRoots {
41    fn from(node: Node) -> Self {
42        Self::Single(node)
43    }
44}
45
46impl From<Vec<Node>> for GraphRoots {
47    fn from(roots: Vec<Node>) -> Self {
48        Self::Many(roots)
49    }
50}
51
52impl<const N: usize> From<[Node; N]> for GraphRoots {
53    fn from(roots: [Node; N]) -> Self {
54        Self::Many(roots.into_iter().collect())
55    }
56}
57
58/// Handle for a node that has already been mounted into a runtime graph.
59///
60/// This is the Rust-native fast path: keep the handle around and update the
61/// mounted node directly instead of rebuilding and reconciling a new graph.
62///
63/// # Fast-Path Updates
64///
65/// To enable efficient parameter updates:
66/// 1. Create a graph with keyed nodes: `el::const_with_key("my_param", value)`
67/// 2. Mount the graph: `mounted = graph.mount()?`
68/// 3. Retrieve the node by key: `mounted.node_with_key("my_param")?`
69/// 4. Send direct updates: `mounted.node.set_const_value(new_value)`
70///
71/// This avoids rebuilding and reconciling the entire graph on each parameter change.
72///
73/// # Example
74///
75/// ```ignore
76/// let mounted = Graph::new()
77///     .render(el::const_with_key("freq", 440.0))
78///     .mount();
79///
80/// // Update frequency without graph rebuild
81/// if let Some(freq_node) = mounted.node_with_key("freq") {
82///     let update = freq_node.set_const_value(880.0);
83///     runtime.execute(&update);
84/// }
85/// ```
86#[derive(Debug, Clone)]
87pub struct MountedNode {
88    node_id: NodeId,
89    kind: String,
90    key: Option<String>,
91}
92
93impl MountedNode {
94    /// Returns the runtime node id.
95    pub fn id(&self) -> NodeId {
96        self.node_id
97    }
98
99    /// Returns the node kind.
100    pub fn kind(&self) -> &str {
101        &self.kind
102    }
103
104    /// Returns the author-supplied key, if present.
105    pub fn key(&self) -> Option<&str> {
106        self.key.as_deref()
107    }
108
109    /// Creates a direct property update batch for this mounted node.
110    pub fn set_property(
111        &self,
112        property: impl Into<String>,
113        value: serde_json::Value,
114    ) -> InstructionBatch {
115        let mut batch = InstructionBatch::new();
116        batch.push(Instruction::SetProperty {
117            node_id: self.node_id,
118            property: property.into(),
119            value,
120        });
121        batch.push(Instruction::CommitUpdates);
122        batch
123    }
124
125    /// Convenience for updating a mounted `const` node's numeric value.
126    pub fn set_const_value(&self, value: f64) -> InstructionBatch {
127        self.set_property("value", serde_json::json!(value))
128    }
129}
130
131/// Lowered graph plus mounted-node handles for direct updates.
132#[derive(Debug, Clone, Default)]
133pub struct MountedGraph {
134    batch: InstructionBatch,
135    roots: Vec<MountedNode>,
136    nodes: Vec<(Vec<usize>, MountedNode)>,
137    keyed_nodes: Vec<(String, MountedNode)>,
138}
139
140impl MountedGraph {
141    /// Returns the full instruction batch for the mounted graph.
142    pub fn batch(&self) -> &InstructionBatch {
143        &self.batch
144    }
145
146    /// Consumes the mounted graph and returns its instruction batch.
147    pub fn into_batch(self) -> InstructionBatch {
148        self.batch
149    }
150
151    /// Returns the mounted root nodes in channel order.
152    pub fn roots(&self) -> &[MountedNode] {
153        &self.roots
154    }
155
156    /// Returns a mounted node by structural path.
157    pub fn node_at(&self, path: &[usize]) -> Option<MountedNode> {
158        self.nodes
159            .iter()
160            .find(|(node_path, _)| node_path.as_slice() == path)
161            .map(|(_, node)| node.clone())
162    }
163
164    /// Returns a mounted node by author-supplied key.
165    pub fn node_with_key(&self, key: &str) -> Option<MountedNode> {
166        self.keyed_nodes
167            .iter()
168            .find(|(node_key, _)| node_key == key)
169            .map(|(_, node)| node.clone())
170    }
171
172    /// Returns an iterator over all mounted nodes with their structural paths.
173    pub fn all_nodes(&self) -> impl Iterator<Item = (&[usize], &MountedNode)> {
174        self.nodes
175            .iter()
176            .map(|(path, node)| (path.as_slice(), node))
177    }
178
179    /// Convenience for updating a keyed `const` node's numeric value.
180    pub fn set_const_value(&self, key: &str, value: f64) -> Option<InstructionBatch> {
181        let node = self.node_with_key(key)?;
182
183        if node.kind() != "const" {
184            return None;
185        }
186
187        Some(node.set_const_value(value))
188    }
189}
190
191impl Graph {
192    /// Creates an empty graph.
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Adds one or more output roots and returns the graph.
198    pub fn render<R>(mut self, roots: R) -> Self
199    where
200        R: Into<GraphRoots>,
201    {
202        match roots.into() {
203            GraphRoots::Single(node) => self.roots.push(node),
204            GraphRoots::Many(roots) => self.roots.extend(roots),
205        }
206        self
207    }
208
209    /// Backward-compatible alias for `render`.
210    pub fn root<R>(self, roots: R) -> Self
211    where
212        R: Into<GraphRoots>,
213    {
214        self.render(roots)
215    }
216
217    /// Backward-compatible alias for `render`.
218    pub fn with_root<R>(self, roots: R) -> Self
219    where
220        R: Into<GraphRoots>,
221    {
222        self.render(roots)
223    }
224
225    /// Lowers the graph and keeps mounted-node handles for direct updates.
226    ///
227    /// Returns `Err(MountError::DuplicateKey(...))` if two nodes in the
228    /// graph share the same `key` prop.
229    pub fn mount(&self) -> std::result::Result<MountedGraph, MountError> {
230        let mut next_id: NodeId = 1;
231        self.mount_with_id_counter(&mut next_id)
232    }
233
234    /// Lowers the graph using an external node-ID counter.
235    ///
236    /// Each call advances `next_id` past all allocated IDs. This allows
237    /// successive graph rebuilds to produce unique node IDs within the
238    /// same runtime session, avoiding `NodeAlreadyExists` errors.
239    ///
240    /// Returns `Err(MountError::DuplicateKey(...))` if two nodes in the
241    /// graph share the same `key` prop.
242    pub fn mount_with_id_counter(
243        &self,
244        next_id: &mut NodeId,
245    ) -> std::result::Result<MountedGraph, MountError> {
246        let mut batch = InstructionBatch::new();
247        let mut mounted = MountedGraph::default();
248
249        let mut lowered_roots = Vec::with_capacity(self.roots.len());
250
251        for (channel, root) in self.roots.iter().enumerate() {
252            let root_id = *next_id;
253            *next_id += 1;
254
255            batch.push(Instruction::CreateNode {
256                node_id: root_id,
257                node_type: "root".to_string(),
258            });
259            batch.push(Instruction::SetProperty {
260                node_id: root_id,
261                property: "channel".to_string(),
262                value: serde_json::json!(channel),
263            });
264
265            let child_id = *next_id;
266            *next_id += 1;
267            let path = vec![channel];
268            let mounted_root = lower_node(
269                root,
270                child_id,
271                &path,
272                next_id,
273                &mut batch,
274                &mut mounted.nodes,
275                &mut mounted.keyed_nodes,
276            )?;
277
278            batch.push(Instruction::AppendChild {
279                parent_id: root_id,
280                child_id,
281                child_output_channel: root.output_channel as i32,
282            });
283            lowered_roots.push(root_id);
284            mounted.roots.push(mounted_root);
285        }
286
287        batch.push(Instruction::ActivateRoots {
288            roots: lowered_roots,
289        });
290        batch.push(Instruction::CommitUpdates);
291
292        mounted.batch = batch;
293        Ok(mounted)
294    }
295
296    /// Adds one root node.
297    pub fn push_root(&mut self, node: Node) {
298        self.roots.push(node);
299    }
300
301    /// Returns the graph roots.
302    pub fn roots(&self) -> &[Node] {
303        &self.roots
304    }
305
306    /// Lowers the graph into a runtime instruction batch.
307    ///
308    /// Panics if the graph contains duplicate keyed nodes. Prefer
309    /// [`mount`] for fallible access.
310    pub fn lower(&self) -> InstructionBatch {
311        self.mount()
312            .expect("graph contains duplicate keys")
313            .into_batch()
314    }
315}
316
317/// A graph node.
318#[derive(Debug, Clone)]
319pub struct Node {
320    kind: String,
321    props: serde_json::Value,
322    children: Vec<Node>,
323    output_channel: usize,
324}
325
326impl Node {
327    pub(crate) fn new(
328        kind: impl Into<String>,
329        props: serde_json::Value,
330        children: Vec<Node>,
331    ) -> Self {
332        Self {
333            kind: kind.into(),
334            props,
335            children,
336            output_channel: 0,
337        }
338    }
339
340    pub(crate) fn with_output_channel(mut self, output_channel: usize) -> Self {
341        self.output_channel = output_channel;
342        self
343    }
344
345    pub fn kind(&self) -> &str {
346        &self.kind
347    }
348
349    pub fn props(&self) -> &serde_json::Value {
350        &self.props
351    }
352
353    pub fn children(&self) -> &[Node] {
354        &self.children
355    }
356
357    pub fn output_channel(&self) -> usize {
358        self.output_channel
359    }
360}
361
362pub type Value = serde_json::Value;
363
364fn lower_node(
365    node: &Node,
366    node_id: NodeId,
367    path: &[usize],
368    next_id: &mut NodeId,
369    batch: &mut InstructionBatch,
370    mounted_nodes: &mut Vec<(Vec<usize>, MountedNode)>,
371    keyed_nodes: &mut Vec<(String, MountedNode)>,
372) -> std::result::Result<MountedNode, MountError> {
373    batch.push(Instruction::CreateNode {
374        node_id,
375        node_type: node.kind.clone(),
376    });
377
378    if let Value::Object(props) = &node.props {
379        for (key, value) in props {
380            batch.push(Instruction::SetProperty {
381                node_id,
382                property: key.clone(),
383                value: value.clone(),
384            });
385        }
386    }
387
388    for (child_index, child) in node.children.iter().enumerate() {
389        let child_id = *next_id;
390        *next_id += 1;
391
392        let mut child_path = path.to_vec();
393        child_path.push(child_index);
394
395        lower_node(
396            child,
397            child_id,
398            &child_path,
399            next_id,
400            batch,
401            mounted_nodes,
402            keyed_nodes,
403        )?;
404        batch.push(Instruction::AppendChild {
405            parent_id: node_id,
406            child_id,
407            child_output_channel: child.output_channel as i32,
408        });
409    }
410
411    let mounted_node = MountedNode {
412        node_id,
413        kind: node.kind.clone(),
414        key: key_from_props(&node.props),
415    };
416    mounted_nodes.push((path.to_vec(), mounted_node.clone()));
417    if let Some(key) = mounted_node.key.clone() {
418        if keyed_nodes
419            .iter()
420            .any(|(existing_key, _)| existing_key == &key)
421        {
422            log::error!("duplicate mounted node key: {key}");
423            return Err(MountError::DuplicateKey(key));
424        }
425        keyed_nodes.push((key, mounted_node.clone()));
426    }
427    Ok(mounted_node)
428}
429
430fn key_from_props(props: &Value) -> Option<String> {
431    match props {
432        Value::Object(map) => map
433            .get("key")
434            .and_then(|value| value.as_str())
435            .map(|key| key.to_string()),
436        _ => None,
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::Graph;
443    use crate::authoring::{el, extra, mc};
444
445    #[test]
446    fn lowers_multichannel_output_channels_on_append_edges() {
447        let graph = Graph::new().root(mc::sample(
448            serde_json::json!({"path": "a.wav", "channels": 2}),
449            el::const_(1.0),
450        ));
451
452        let payload: serde_json::Value =
453            serde_json::from_str(&graph.lower().to_json_string()).expect("valid batch json");
454        let instructions = payload.as_array().expect("batch is an array");
455        let mut node_types = std::collections::HashMap::new();
456
457        for instruction in instructions {
458            let array = instruction.as_array().expect("instruction is an array");
459            if array.first().and_then(|value| value.as_i64()) == Some(0) {
460                node_types.insert(
461                    array[1].as_i64().expect("node id") as i32,
462                    array[2].as_str().expect("node type"),
463                );
464            }
465        }
466
467        let output_channels: Vec<i64> = instructions
468            .iter()
469            .filter_map(|instruction| {
470                let array = instruction.as_array()?;
471                if array.first()?.as_i64()? != 2 {
472                    return None;
473                }
474
475                let parent_id = array.get(1)?.as_i64()? as i32;
476                if node_types.get(&parent_id).copied() != Some("root") {
477                    return None;
478                }
479
480                array.get(3)?.as_i64()
481            })
482            .collect();
483
484        assert_eq!(output_channels, vec![0, 1]);
485    }
486
487    #[test]
488    fn stride_delay_mounts_with_signal_children() {
489        let delay_ms = el::const_with_key("delay", 250.0);
490        let fb = el::const_with_key("fb", 0.3);
491        let input = el::r#in(serde_json::json!({"channel": 0}), None);
492
493        let delayed = extra::stride_delay(
494            serde_json::json!({ "maxDelayMs": 1500, "transitionMs": 60 }),
495            delay_ms,
496            fb,
497            input,
498        );
499
500        let graph = Graph::new().render(vec![delayed]);
501        let mounted = graph.mount().expect("mount");
502        let batch = mounted.batch();
503
504        // Verify the batch serializes to a non-empty JSON array.
505        let json = batch.to_json_string();
506        assert!(json.len() > 2, "batch should contain instructions");
507
508        // Find the stridedelay node in the mounted graph.
509        let sd_nodes: Vec<_> = mounted
510            .all_nodes()
511            .filter(|(_, n)| n.kind() == "stridedelay")
512            .collect();
513        assert_eq!(sd_nodes.len(), 1, "expected 1 stridedelay node");
514
515        // Verify the keyed const nodes are present.
516        let delay_const = mounted
517            .all_nodes()
518            .find(|(_, n)| n.key().as_deref() == Some("delay"));
519        assert!(delay_const.is_some(), "keyed 'delay' const should exist");
520
521        let fb_const = mounted
522            .all_nodes()
523            .find(|(_, n)| n.key().as_deref() == Some("fb"));
524        assert!(fb_const.is_some(), "keyed 'fb' const should exist");
525    }
526
527    #[test]
528    fn stride_delay_children_order_in_batch() {
529        // Verify children are appended in the correct order:
530        // child 0 = delayMs, child 1 = fb, child 2 = audio
531        let delay_ms = el::const_with_key("delay", 250.0);
532        let fb = el::const_with_key("fb", 0.0);
533        let input = el::r#in(serde_json::json!({"channel": 0}), None);
534
535        let delayed = extra::stride_delay(
536            serde_json::json!({ "maxDelayMs": 1500 }),
537            delay_ms,
538            fb,
539            input,
540        );
541
542        let graph = Graph::new().render(vec![delayed]);
543        let json_str = graph.lower().to_json_string();
544        let payload: serde_json::Value = serde_json::from_str(&json_str).expect("valid batch json");
545        let instructions = payload.as_array().expect("batch is an array");
546
547        // Collect CreateNode instructions to map node_id -> node_type.
548        let mut node_types: std::collections::HashMap<i64, String> =
549            std::collections::HashMap::new();
550        for inst in instructions {
551            let arr = inst.as_array().expect("instruction is array");
552            if arr[0].as_i64() == Some(0) {
553                // CreateNode: [0, node_id, node_type]
554                node_types.insert(
555                    arr[1].as_i64().unwrap(),
556                    arr[2].as_str().unwrap().to_string(),
557                );
558            }
559        }
560
561        // Find the stridedelay node ID.
562        let sd_id = node_types
563            .iter()
564            .find(|(_, t)| t.as_str() == "stridedelay")
565            .map(|(id, _)| *id)
566            .expect("stridedelay node should exist");
567
568        // Collect AppendChild instructions for the stridedelay parent.
569        // AppendChild: [2, parent_id, child_id, output_channel]
570        let children_of_sd: Vec<(i64, String)> = instructions
571            .iter()
572            .filter_map(|inst| {
573                let arr = inst.as_array()?;
574                if arr[0].as_i64()? != 2 {
575                    return None;
576                }
577                if arr[1].as_i64()? != sd_id {
578                    return None;
579                }
580                let child_id = arr[2].as_i64()?;
581                let child_type = node_types.get(&child_id)?.clone();
582                Some((child_id, child_type))
583            })
584            .collect();
585
586        // Expect 3 children: const (delay), const (fb), in (audio).
587        assert_eq!(
588            children_of_sd.len(),
589            3,
590            "stridedelay should have 3 children"
591        );
592        assert_eq!(
593            children_of_sd[0].1, "const",
594            "child 0 should be const (delayMs)"
595        );
596        assert_eq!(children_of_sd[1].1, "const", "child 1 should be const (fb)");
597        assert_eq!(children_of_sd[2].1, "in", "child 2 should be in (audio)");
598    }
599
600    #[test]
601    fn stride_delay_runtime_produces_output() {
602        use crate::Runtime;
603
604        let sr = 44100.0;
605        let block = 64;
606        let runtime = Runtime::new()
607            .sample_rate(sr)
608            .buffer_size(block)
609            .call()
610            .expect("runtime creation");
611
612        // Build a graph: const(250ms delay) -> stride_delay -> root
613        // Feed a simple impulse as input.
614        let delay_ms = el::const_(250.0);
615        let fb = el::const_(0.0);
616        let input = el::r#in(serde_json::json!({"channel": 0}), None);
617
618        let delayed = extra::stride_delay(
619            serde_json::json!({ "maxDelayMs": 500, "transitionMs": 10 }),
620            delay_ms,
621            fb,
622            input,
623        );
624
625        let graph = Graph::new().render(vec![delayed]);
626        let mounted = graph.mount().expect("mount");
627        runtime
628            .apply_instructions(mounted.batch())
629            .expect("apply instructions");
630
631        // Create an impulse input: 1.0 at sample 0, then silence.
632        let mut input_buf = vec![0.0_f64; block];
633        input_buf[0] = 1.0;
634        let mut output_buf = vec![0.0_f64; block];
635
636        let inputs = [input_buf.as_slice()];
637        let mut outputs = [output_buf.as_mut_slice()];
638        runtime
639            .process(block, &inputs, &mut outputs)
640            .expect("process");
641
642        // On the first block with 250ms delay at 44100Hz = 11025 samples,
643        // the impulse won't appear in the output yet (delayed beyond this block).
644        // But the output should NOT be all-zero if the node is processing
645        // (the stride transition from 0ms to 250ms will produce some output).
646        // At minimum, the node should not crash.
647
648        // Run several more blocks to fill the delay buffer.
649        let silence = vec![0.0_f64; block];
650        for _ in 0..200 {
651            let inputs = [silence.as_slice()];
652            let mut out = vec![0.0_f64; block];
653            let mut outputs = [out.as_mut_slice()];
654            runtime
655                .process(block, &inputs, &mut outputs)
656                .expect("process");
657
658            // Check if any non-zero output appeared (the delayed impulse).
659            if outputs[0].iter().any(|&s| s.abs() > 1e-10) {
660                // The delay is working — the impulse came back.
661                return;
662            }
663        }
664
665        panic!("stride delay produced no output after 200 blocks — delay effect is not working");
666    }
667
668    #[test]
669    fn stride_delay_with_computed_children_produces_output() {
670        // Mimics the nel-x MC graph: delay_ms and fb are computed
671        // through el::mul/el::add expressions, not plain consts.
672        use crate::Runtime;
673
674        let sr = 44100.0;
675        let block = 64;
676        let runtime = Runtime::new()
677            .sample_rate(sr)
678            .buffer_size(block)
679            .call()
680            .expect("runtime creation");
681
682        let base_delay = el::const_(250.0);
683        let spread = el::const_(0.0);
684        let offset = el::const_(0.0); // channel 0 = center
685
686        // ch_delay = base_delay * (1.0 + spread * offset) = 250 * 1 = 250
687        let ch_delay = el::mul((
688            base_delay,
689            el::add((1.0, el::mul((spread.clone(), offset.clone())))),
690        ));
691
692        // ch_fb = base_fb * (1 - spread * offset * 0.3) = 0.3 * 1 = 0.3
693        let base_fb = el::const_(0.3);
694        let ch_fb = el::mul((
695            base_fb,
696            el::sub((1.0, el::mul((spread, el::mul((offset, 0.3)))))),
697        ));
698
699        let input = el::r#in(serde_json::json!({"channel": 0}), None);
700
701        let delayed = extra::stride_delay(
702            serde_json::json!({ "maxDelayMs": 500, "transitionMs": 10 }),
703            ch_delay,
704            ch_fb,
705            input,
706        );
707
708        let graph = Graph::new().render(vec![delayed]);
709        let mounted = graph.mount().expect("mount");
710        runtime
711            .apply_instructions(mounted.batch())
712            .expect("apply instructions");
713
714        // Send impulse, then silence, check if the delayed impulse returns.
715        let mut input_buf = vec![0.0_f64; block];
716        input_buf[0] = 1.0;
717        let mut output_buf = vec![0.0_f64; block];
718
719        let inputs = [input_buf.as_slice()];
720        let mut outputs = [output_buf.as_mut_slice()];
721        runtime
722            .process(block, &inputs, &mut outputs)
723            .expect("process");
724
725        let silence = vec![0.0_f64; block];
726        for _ in 0..200 {
727            let inputs = [silence.as_slice()];
728            let mut out = vec![0.0_f64; block];
729            let mut outputs = [out.as_mut_slice()];
730            runtime
731                .process(block, &inputs, &mut outputs)
732                .expect("process");
733
734            if outputs[0].iter().any(|&s| s.abs() > 1e-10) {
735                return; // delay is working with computed children
736            }
737        }
738
739        panic!("stride delay with computed children produced no output");
740    }
741
742    #[test]
743    fn stride_delay_with_mix_blend() {
744        // Test the full wet/dry blend path as used in the plugin.
745        use crate::Runtime;
746
747        let sr = 44100.0;
748        let block = 512;
749        let runtime = Runtime::new()
750            .sample_rate(sr)
751            .buffer_size(block)
752            .call()
753            .expect("runtime creation");
754
755        let delay_ms = el::const_(50.0); // Short delay for quick test
756        let fb = el::const_(0.0);
757        let mix_val = 0.5;
758        let mix_wet = el::const_(mix_val);
759        let mix_dry = el::const_(mix_val);
760        let input = el::r#in(serde_json::json!({"channel": 0}), None);
761
762        let delayed = extra::stride_delay(
763            serde_json::json!({ "maxDelayMs": 200, "transitionMs": 10 }),
764            delay_ms,
765            fb,
766            input.clone(),
767        );
768
769        // Manual wet/dry: output = delayed * mix + input * (1 - mix)
770        let wet = el::mul((delayed, mix_wet));
771        let dry = el::mul((input, el::sub((1.0, mix_dry))));
772        let out = el::add((wet, dry));
773
774        let graph = Graph::new().render(vec![out]);
775        let mounted = graph.mount().expect("mount");
776        runtime
777            .apply_instructions(mounted.batch())
778            .expect("apply instructions");
779
780        // Send continuous signal (not just an impulse).
781        let input_signal: Vec<f64> = (0..block)
782            .map(|i| if i < 100 { 1.0 } else { 0.0 })
783            .collect();
784        let mut output_buf = vec![0.0_f64; block];
785
786        let inputs = [input_signal.as_slice()];
787        let mut outputs = [output_buf.as_mut_slice()];
788        runtime
789            .process(block, &inputs, &mut outputs)
790            .expect("process");
791
792        // Check if dry path produces output. May take one block to propagate.
793        let first_block_max = outputs[0].iter().copied().fold(0.0_f64, f64::max);
794        eprintln!("first block max output: {first_block_max}");
795
796        // Run second block with same input to check propagation.
797        let mut output_buf2 = vec![0.0_f64; block];
798        let inputs2 = [input_signal.as_slice()];
799        let mut outputs2 = [output_buf2.as_mut_slice()];
800        runtime
801            .process(block, &inputs2, &mut outputs2)
802            .expect("process 2");
803        let second_block_max = outputs2[0].iter().copied().fold(0.0_f64, f64::max);
804        eprintln!("second block max output: {second_block_max}");
805
806        assert!(
807            first_block_max > 0.01 || second_block_max > 0.01,
808            "dry path should produce output within first two blocks, got max={first_block_max}, {second_block_max}"
809        );
810
811        // After the delay time (50ms = 2205 samples at 44100Hz),
812        // we need more blocks. Run more.
813        let silence = vec![0.0_f64; block];
814        let mut found_delayed = false;
815
816        for block_num in 0..20 {
817            let inputs = [silence.as_slice()];
818            let mut out = vec![0.0_f64; block];
819            let mut outputs = [out.as_mut_slice()];
820            runtime
821                .process(block, &inputs, &mut outputs)
822                .expect("process");
823
824            // After input stops, any non-zero output is from the delay.
825            if outputs[0].iter().any(|&s| s.abs() > 0.01) {
826                found_delayed = true;
827                eprintln!(
828                    "delayed signal appeared in block {} (sample ~{})",
829                    block_num + 1,
830                    (block_num + 1) * block
831                );
832                break;
833            }
834        }
835
836        assert!(
837            found_delayed,
838            "delay effect should produce output after the dry signal stops"
839        );
840    }
841
842    #[test]
843    fn mount_returns_error_on_duplicate_key() {
844        use crate::graph::MountError;
845
846        // Two const nodes sharing the same key.
847        let a = el::const_with_key("dup", 1.0);
848        let b = el::const_with_key("dup", 2.0);
849        let out = el::add((a, b));
850
851        let graph = Graph::new().render(vec![out]);
852        let result = graph.mount();
853
854        match result {
855            Err(MountError::DuplicateKey(key)) => {
856                assert_eq!(key, "dup");
857            }
858            Ok(_) => panic!("expected DuplicateKey error, got Ok"),
859        }
860    }
861}