elemaudio_rs/authoring/
extra.rs

1use super::el;
2use crate::graph::Node;
3use crate::{resolve, unpack, ElemNode};
4
5/// Internal enum for box_sum window input (props or signal).
6pub enum BoxSumWindowInput {
7    /// Props object with static window and optional key
8    Props(serde_json::Value),
9    /// Dynamic signal node for sample-rate modulation
10    Signal(ElemNode),
11}
12
13impl From<serde_json::Value> for BoxSumWindowInput {
14    fn from(props: serde_json::Value) -> Self {
15        BoxSumWindowInput::Props(props)
16    }
17}
18
19impl From<Node> for BoxSumWindowInput {
20    fn from(node: Node) -> Self {
21        BoxSumWindowInput::Signal(ElemNode::Node(node))
22    }
23}
24
25impl From<f64> for BoxSumWindowInput {
26    fn from(value: f64) -> Self {
27        BoxSumWindowInput::Signal(ElemNode::Number(value))
28    }
29}
30
31fn channels_and_props(mut props: serde_json::Value) -> (usize, serde_json::Value) {
32    let channels = props
33        .get("channels")
34        .and_then(|value| value.as_u64())
35        .expect("extra helper props must include a positive integer `channels`")
36        as usize;
37
38    if let serde_json::Value::Object(map) = &mut props {
39        map.remove("channels");
40    }
41
42    (channels, props)
43}
44
45/// Frequency shifter helper.
46///
47/// Returns two roots:
48/// - output 0: down-shifted
49/// - output 1: up-shifted
50///
51/// Props:
52/// - `shiftHz`: frequency shift in Hz
53/// - `mix`: wet amount in the range `0.0..=1.0`
54/// - `reflect`: integer mode for negative shift handling
55pub fn freqshift(props: serde_json::Value, x: impl Into<ElemNode>) -> Vec<Node> {
56    unpack(Node::new("freqshift", props, vec![resolve(x)]), 2)
57}
58
59/// Crunch distortion helper.
60///
61/// Returns one root per output channel.
62///
63/// Props:
64/// - `channels`: number of channels to unpack
65/// - `drive`: pre-distortion input gain
66/// - `fuzz`: amplitude-independent distortion amount
67/// - `toneHz`: tone control frequency
68/// - `cutHz`: pre-distortion high-pass frequency
69/// - `outGain`: output gain
70/// - `autoGain`: enable auto gain compensation
71pub fn crunch(props: serde_json::Value, x: impl Into<ElemNode>) -> Vec<Node> {
72    let (channels, props) = channels_and_props(props);
73    unpack(Node::new("crunch", props, vec![resolve(x)]), channels)
74}
75
76/// Recursive foldback shaper helper.
77///
78/// Computes a recursive soft-saturation using fold-back distortion. The shape
79/// depends on the threshold and gain parameters.
80///
81/// For best performance with the fast-path update system, supply a `key` prefix.
82/// This allows threshold and amplitude to be updated **without rebuilding the graph**.
83///
84/// Props:
85/// - `key`: optional prefix for stable node identity; enables direct updates via
86///   `mounted.node_with_key("{key}_thresh")` and `mounted.node_with_key("{key}_amp")`
87/// - `thresh`: fold threshold, must be positive
88/// - `amp`: output gain, defaults to `1 / thresh`
89///
90/// # Example: Keyed foldback with parameter updates
91///
92/// ```ignore
93/// use elemaudio_rs::{Graph, el, extra};
94/// use serde_json::json;
95///
96/// // Create a graph with a keyed foldback
97/// let graph = Graph::new().render(
98///     extra::foldback(
99///         json!({
100///             "key": "shaper",
101///             "thresh": 0.5,
102///             "amp": 2.0,
103///         }),
104///         el::cycle(el::const_(440.0)),
105///     )
106/// );
107///
108/// // Mount the graph and get handles for direct updates
109/// let mounted = graph.mount();
110/// let batch = mounted.into_batch();
111/// runtime.execute(&batch);
112///
113/// // Later, update threshold WITHOUT rebuilding the entire graph
114/// if let Some(thresh_node) = mounted.node_with_key("shaper_thresh") {
115///     let update = thresh_node.set_const_value(0.7);
116///     runtime.execute(&update);
117/// }
118///
119/// // Similarly for amplitude
120/// if let Some(amp_node) = mounted.node_with_key("shaper_amp") {
121///     let update = amp_node.set_const_value(1.5);
122///     runtime.execute(&update);
123/// }
124/// ```
125pub fn foldback(props: serde_json::Value, x: impl Into<ElemNode>) -> Node {
126    let mut props = props;
127
128    // Extract and validate threshold
129    let thresh = props
130        .get("thresh")
131        .and_then(|value| value.as_f64())
132        .expect("foldback helper props must include a positive `thresh` value");
133
134    if !(thresh.is_finite() && thresh > 0.0) {
135        panic!("foldback helper props must include a positive `thresh` value");
136    }
137
138    // Extract amplitude, defaulting to 1 / thresh
139    let amp = props
140        .get("amp")
141        .and_then(|value| value.as_f64())
142        .filter(|value| value.is_finite())
143        .unwrap_or(1.0 / thresh);
144
145    // Extract optional key prefix for fast-path updates
146    let key_prefix = props
147        .get("key")
148        .and_then(|value| value.as_str())
149        .map(|k| k.to_string());
150
151    // Remove control props before constructing the graph
152    if let serde_json::Value::Object(map) = &mut props {
153        map.remove("thresh");
154        map.remove("amp");
155        map.remove("key");
156    }
157
158    let x = resolve(x);
159
160    // Create const nodes with keys if a prefix was supplied
161    let thresh_node = match &key_prefix {
162        Some(prefix) => el::const_with_key(&format!("{}_thresh", prefix), thresh),
163        None => el::const_(thresh),
164    };
165
166    let amp_node = match &key_prefix {
167        Some(prefix) => el::const_with_key(&format!("{}_amp", prefix), amp),
168        None => el::const_(amp),
169    };
170
171    // Build the foldback computation graph
172    let folded = el::sub([
173        el::abs(el::sub([
174            el::abs(el::r#mod(
175                el::sub([x.clone(), thresh_node.clone()]),
176                el::mul([el::const_(4.0), thresh_node.clone()]),
177            )),
178            el::mul([el::const_(2.0), thresh_node.clone()]),
179        ])),
180        thresh_node.clone(),
181    ]);
182    let should_fold = el::ge(el::abs(x.clone()), thresh_node);
183
184    Node::new(
185        "mul",
186        props,
187        vec![amp_node, el::select(should_fold, folded, x)],
188    )
189}
190
191/// VariSlope SVF — cascaded Butterworth SVF with Rossum-style continuous slope
192/// morphing.
193///
194/// # Overview
195///
196/// This node exposes a continuously variable filter order (slope) that morphs
197/// smoothly between 1 and 6 cascaded second-order Butterworth SVF stages
198/// (12–72 dB/oct) at audio rate, inspired by Dave Rossum's analog cascade
199/// designs.
200///
201/// Q is fixed internally at Butterworth (√2 ≈ 1.414, maximally flat magnitude)
202/// and is not exposed. The slope is the sole tonal control: one knob that
203/// determines how aggressively the filter rolls off.
204///
205/// All six internal stages run every sample so their integrator states remain
206/// warm regardless of the current slope value. The output is a linear crossfade
207/// between the two adjacent integer-order outputs that bracket the current slope,
208/// so the filter order morphs without clicks, discontinuities, or dropout.
209///
210/// Per-stage gain correction (matched magnitude at cutoff) prevents the BLT
211/// passband droop from compounding across stages.
212///
213/// # Contrast with `el.svf`
214///
215/// The vendor `el.svf` is a single-stage Simper SVF (12 dB/oct fixed order)
216/// with an exposed Q parameter. `vari_slope_svf` removes Q and adds continuous
217/// Butterworth slope morphing as its defining feature.
218///
219/// # Inputs
220///
221/// | Index | Signal      | Required | Default  | Notes                         |
222/// |-------|-------------|----------|----------|-------------------------------|
223/// | 0     | `cutoff_hz` | yes      | —        | Cutoff frequency in Hz        |
224/// | 1     | `audio`     | yes      | —        | Audio input signal            |
225/// | 2     | `slope`     | no       | `1.0`    | Continuous order \[1.0, 6.0\] |
226///
227/// # Properties
228///
229/// | Key          | Type   | Values                              |
230/// |--------------|--------|-------------------------------------|
231/// | `filterType` | string | `"lowpass"` / `"lp"` (default)      |
232/// |              |        | `"highpass"` / `"hp"`               |
233///
234/// # Example
235///
236/// ```ignore
237/// use elemaudio_rs::{el, extra};
238/// use serde_json::json;
239///
240/// // Static 24 dB/oct lowpass at 800 Hz (slope = Some).
241/// let node = extra::vari_slope_svf(
242///     json!({ "filterType": "lowpass" }),
243///     el::const_(json!({ "value": 800.0 })),  // cutoff
244///     source,                                   // audio
245///     Some(el::const_(json!({ "value": 2.0 }))),// slope = 24 dB/oct
246/// );
247///
248/// // Default slope (12 dB/oct) — pass None.
249/// let node = extra::vari_slope_svf(
250///     json!({ "filterType": "lowpass" }),
251///     el::const_(json!({ "value": 800.0 })),
252///     source,
253///     None::<Node>,
254/// );
255///
256/// // Slope swept from 1.0 → 6.0 by an LFO for a dynamic order morph.
257/// let slope_lfo = el::add(
258///     el::const_(json!({ "value": 3.5 })),
259///     el::mul(el::const_(json!({ "value": 2.5 })),
260///             el::cycle(el::const_(json!({ "value": 0.25 })))),
261/// );
262/// let node = extra::vari_slope_svf(
263///     json!({ "filterType": "lowpass" }),
264///     cutoff, source, Some(slope_lfo),
265/// );
266/// ```
267pub fn vari_slope_svf(
268    props: serde_json::Value,
269    cutoff: impl Into<ElemNode>,
270    audio: impl Into<ElemNode>,
271    slope: Option<impl Into<ElemNode>>,
272) -> Node {
273    let mut children = vec![resolve(cutoff), resolve(audio)];
274    if let Some(s) = slope {
275        children.push(resolve(s));
276    }
277    Node::new("variSlopeSvf", props, children)
278}
279
280/// STFT-based channel vocoder.
281///
282/// Port of Geraint Luff's JSFX Vocoder. Imposes the spectral envelope of the
283/// modulator signal onto the carrier signal using per-bin energy envelope
284/// following and overlap-add reconstruction.
285///
286/// # Inputs
287///
288/// | Index | Signal        | Required | Notes                  |
289/// |-------|---------------|----------|------------------------|
290/// | 0     | carrier L     | yes      | Left carrier channel   |
291/// | 1     | carrier R     | yes      | Right carrier channel  |
292/// | 2     | modulator L   | yes      | Left modulator channel |
293/// | 3     | modulator R   | yes      | Right modulator channel|
294///
295/// # Outputs
296///
297/// Returns 2 outputs: vocoded left and right channels.
298///
299/// # Properties
300///
301/// | Key           | Type   | Range   | Default | Notes                      |
302/// |---------------|--------|---------|---------|----------------------------|
303/// | `windowMs`    | number | 1–100   | 10      | FFT window length in ms    |
304/// | `smoothingMs` | number | 0–2000  | 5       | Energy smoothing in ms (high values = spectral sustain) |
305/// | `maxGainDb`   | number | 0–100   | 40      | Per-band gain ceiling (dB) |
306///
307/// # Example
308///
309/// ```ignore
310/// use elemaudio_rs::{el, extra};
311/// use serde_json::json;
312///
313/// let vocoded = extra::vocoder(
314///     json!({ "windowMs": 10, "smoothingMs": 5, "maxGainDb": 40 }),
315///     carrier_l, carrier_r,
316///     modulator_l, modulator_r,
317/// );
318/// // vocoded is a Vec of 2 nodes: [out_l, out_r]
319/// ```
320pub fn vocoder(
321    props: serde_json::Value,
322    carrier_l: impl Into<ElemNode>,
323    carrier_r: impl Into<ElemNode>,
324    modulator_l: impl Into<ElemNode>,
325    modulator_r: impl Into<ElemNode>,
326) -> Vec<Node> {
327    let children = vec![
328        resolve(carrier_l),
329        resolve(carrier_r),
330        resolve(modulator_l),
331        resolve(modulator_r),
332    ];
333    unpack(Node::new("vocoder", props, children), 2)
334}
335
336/// Raw variable-width box sum helper.
337///
338/// Computes a box-filter sum over a configurable window length.
339///
340/// Supports two usage patterns:
341///
342/// 1. **Static window with keying** (for fast-path parameter updates):
343///    Pass props with `window` and `key`. The window can be updated via
344///    `mounted.node_with_key("{key}_window")` without rebuilding the graph.
345///
346/// 2. **Dynamic window signal** (for sample-rate modulation):
347///    Pass a signal node as the window parameter for runtime sample-rate control.
348///    No keying is available in this mode.
349///
350/// # Example: Keyed static window with fast-path updates
351///
352/// ```ignore
353/// use elemaudio_rs::{Graph, el, extra};
354/// use serde_json::json;
355///
356/// let graph = Graph::new().render(
357///     extra::box_sum(
358///         json!({ "key": "boxfilter", "window": 256.0 }),
359///         el::cycle(el::const_(440.0)),
360///     )
361/// );
362///
363/// let mounted = graph.mount();
364/// let batch = mounted.into_batch();
365/// runtime.execute(&batch);
366///
367/// // Later, update window size without rebuilding the graph
368/// if let Some(window_node) = mounted.node_with_key("boxfilter_window") {
369///     let update = window_node.set_const_value(512.0);
370///     runtime.execute(&update);
371/// }
372/// ```
373///
374/// # Example: Dynamic signal window for sample-rate modulation
375///
376/// ```ignore
377/// use elemaudio_rs::{Graph, el, extra, WindowParam};
378/// use serde_json::json;
379///
380/// // Window size modulated by an LFO at sample rate
381/// let window_lfo = el::mul((
382///     el::add([el::const_(256.0), el::const_(128.0)]),
383///     el::cycle(el::const_(0.5)),  // 0.5 Hz LFO
384/// ));
385///
386/// let graph = Graph::new().render(
387///     extra::box_sum(
388///         WindowParam::Dynamic(window_lfo),
389///         el::cycle(el::const_(440.0)),
390///     )
391/// );
392///
393/// let mounted = graph.mount();
394/// runtime.execute(&mounted.into_batch());
395/// ```
396pub fn box_sum(window: impl Into<BoxSumWindowInput>, x: impl Into<ElemNode>) -> Node {
397    match window.into() {
398        BoxSumWindowInput::Props(props) => box_sum_from_props(props, x),
399        BoxSumWindowInput::Signal(window_signal) => Node::new(
400            "boxsum",
401            serde_json::Value::Null,
402            vec![resolve(window_signal), resolve(x)],
403        ),
404    }
405}
406
407/// Internal helper to construct box_sum from props with keying support.
408fn box_sum_from_props(props: serde_json::Value, x: impl Into<ElemNode>) -> Node {
409    let mut props = props;
410
411    // Extract and validate window length
412    let window = props
413        .get("window")
414        .and_then(|value| value.as_f64())
415        .expect("box_sum helper props must include a positive numeric `window` value");
416
417    if !(window.is_finite() && window > 0.0) {
418        panic!("box_sum helper props must include a positive numeric `window` value");
419    }
420
421    // Extract optional key prefix for fast-path updates
422    let key_prefix = props
423        .get("key")
424        .and_then(|value| value.as_str())
425        .map(|k| k.to_string());
426
427    // Remove control props before constructing the graph
428    if let serde_json::Value::Object(map) = &mut props {
429        map.remove("window");
430        map.remove("key");
431    }
432
433    // Create window node with key if a prefix was supplied
434    let window_node = match &key_prefix {
435        Some(prefix) => el::const_with_key(&format!("{}_window", prefix), window),
436        None => el::const_(window),
437    };
438
439    Node::new("boxsum", props, vec![window_node, resolve(x)])
440}
441
442/// Raw variable-width box average helper.
443///
444/// Computes a box-filter average over a configurable window length.
445///
446/// Supports two usage patterns:
447///
448/// 1. **Static window with keying** (for fast-path parameter updates):
449///    Pass props with `window` and `key`. The window can be updated via
450///    `mounted.node_with_key("{key}_window")` without rebuilding the graph.
451///
452/// 2. **Dynamic window signal** (for sample-rate modulation):
453///    Pass a signal node as the window parameter for runtime sample-rate control.
454///    No keying is available in this mode.
455///
456/// # Example: Keyed static window with fast-path updates
457///
458/// ```ignore
459/// use elemaudio_rs::{Graph, el, extra};
460/// use serde_json::json;
461///
462/// let graph = Graph::new().render(
463///     extra::box_average(
464///         json!({ "key": "boxavg", "window": 256.0 }),
465///         el::cycle(el::const_(440.0)),
466///     )
467/// );
468///
469/// let mounted = graph.mount();
470/// let batch = mounted.into_batch();
471/// runtime.execute(&batch);
472///
473/// // Later, update window size without rebuilding the graph
474/// if let Some(window_node) = mounted.node_with_key("boxavg_window") {
475///     let update = window_node.set_const_value(512.0);
476///     runtime.execute(&update);
477/// }
478/// ```
479///
480/// # Example: Dynamic signal window for sample-rate modulation
481///
482/// ```ignore
483/// use elemaudio_rs::{Graph, el, extra};
484///
485/// // Window size modulated by an LFO at sample rate
486/// let window_lfo = el::mul((
487///     el::add([el::const_(256.0), el::const_(128.0)]),
488///     el::cycle(el::const_(0.5)),  // 0.5 Hz LFO
489/// ));
490///
491/// let graph = Graph::new().render(
492///     extra::box_average(
493///         window_lfo,
494///         el::white(),
495///     )
496/// );
497///
498/// let mounted = graph.mount();
499/// runtime.execute(&mounted.into_batch());
500/// ```
501pub fn box_average(window: impl Into<BoxAverageWindowInput>, x: impl Into<ElemNode>) -> Node {
502    match window.into() {
503        BoxAverageWindowInput::Props(props) => box_average_from_props(props, x),
504        BoxAverageWindowInput::Signal(window_signal) => Node::new(
505            "boxaverage",
506            serde_json::Value::Null,
507            vec![resolve(window_signal), resolve(x)],
508        ),
509    }
510}
511
512/// Internal enum for box_average window input (props or signal).
513pub enum BoxAverageWindowInput {
514    /// Props object with static window and optional key
515    Props(serde_json::Value),
516    /// Dynamic signal node for sample-rate modulation
517    Signal(ElemNode),
518}
519
520impl From<serde_json::Value> for BoxAverageWindowInput {
521    fn from(props: serde_json::Value) -> Self {
522        BoxAverageWindowInput::Props(props)
523    }
524}
525
526impl From<Node> for BoxAverageWindowInput {
527    fn from(node: Node) -> Self {
528        BoxAverageWindowInput::Signal(ElemNode::Node(node))
529    }
530}
531
532impl From<f64> for BoxAverageWindowInput {
533    fn from(value: f64) -> Self {
534        BoxAverageWindowInput::Signal(ElemNode::Number(value))
535    }
536}
537
538/// Internal helper to construct box_average from props with keying support.
539fn box_average_from_props(props: serde_json::Value, x: impl Into<ElemNode>) -> Node {
540    let mut props = props;
541
542    // Extract and validate window length
543    let window = props
544        .get("window")
545        .and_then(|value| value.as_f64())
546        .expect("box_average helper props must include a positive numeric `window` value");
547
548    if !(window.is_finite() && window > 0.0) {
549        panic!("box_average helper props must include a positive numeric `window` value");
550    }
551
552    // Extract optional key prefix for fast-path updates
553    let key_prefix = props
554        .get("key")
555        .and_then(|value| value.as_str())
556        .map(|k| k.to_string());
557
558    // Remove control props before constructing the graph
559    if let serde_json::Value::Object(map) = &mut props {
560        map.remove("window");
561        map.remove("key");
562    }
563
564    // Create window node with key if a prefix was supplied
565    let window_node = match &key_prefix {
566        Some(prefix) => el::const_with_key(&format!("{}_window", prefix), window),
567        None => el::const_(window),
568    };
569
570    Node::new("boxaverage", props, vec![window_node, resolve(x)])
571}
572
573/// Native lookahead limiter helper (mono).
574///
575/// # Props
576///
577/// | Key               | Type   | Default              | Notes                   |
578/// |-------------------|--------|----------------------|-------------------------|
579/// | `key`             | string | —                    | Optional node identity  |
580/// | `maxDelayMs`      | number | 100                  | Lookahead buffer length |
581/// | `inputGain`       | number | 1                    | Pre-limiter gain        |
582/// | `outputLimit`     | number | 10^(-3/20) ≈ 0.708  | Maximum output level    |
583/// | `attackMs`        | number | 20                   | Attack time             |
584/// | `holdMs`          | number | 0                    | Hold time after peaks   |
585/// | `releaseMs`       | number | 0                    | Extra release time      |
586/// | `smoothingStages` | number | 1                    | Smoothing filter stages |
587/// | `linkChannels`    | number | 0.5                  | Channel gain linking    |
588pub fn limiter(props: serde_json::Value, x: impl Into<ElemNode>) -> Node {
589    let resolved_props = limiter_resolve_defaults(props);
590    Node::new("limiter", resolved_props, vec![resolve(x)])
591}
592
593/// Native lookahead limiter helper (stereo).
594///
595/// Same props as [`limiter`]; takes two audio inputs and returns
596/// two output nodes via `unpack`.
597pub fn stereo_limiter(
598    props: serde_json::Value,
599    left: impl Into<ElemNode>,
600    right: impl Into<ElemNode>,
601) -> Vec<Node> {
602    let resolved_props = limiter_resolve_defaults(props);
603    unpack(
604        Node::new(
605            "limiter",
606            resolved_props,
607            vec![resolve(left), resolve(right)],
608        ),
609        2,
610    )
611}
612
613/// Apply defaults for limiter props to match the TS surface.
614fn limiter_resolve_defaults(props: serde_json::Value) -> serde_json::Value {
615    let mut map = match props {
616        serde_json::Value::Object(m) => m,
617        _ => serde_json::Map::new(),
618    };
619
620    map.entry("maxDelayMs")
621        .or_insert_with(|| serde_json::Value::from(100.0));
622    map.entry("inputGain")
623        .or_insert_with(|| serde_json::Value::from(1.0));
624    // -3 dBFS = 10^(-3/20)
625    map.entry("outputLimit")
626        .or_insert_with(|| serde_json::Value::from(10.0_f64.powf(-3.0 / 20.0)));
627    map.entry("attackMs")
628        .or_insert_with(|| serde_json::Value::from(20.0));
629    map.entry("holdMs")
630        .or_insert_with(|| serde_json::Value::from(0.0));
631    map.entry("releaseMs")
632        .or_insert_with(|| serde_json::Value::from(0.0));
633    map.entry("smoothingStages")
634        .or_insert_with(|| serde_json::Value::from(1));
635    map.entry("linkChannels")
636        .or_insert_with(|| serde_json::Value::from(0.5));
637
638    serde_json::Value::Object(map)
639}
640
641/// Stride-interpolated delay helper (mono).
642///
643/// `delay_ms` and `fb` are signal children read at sample rate by the
644/// native node. Use `el::const_with_key(...)` for fast-path parameter
645/// updates, or any signal expression for modulation.
646///
647/// # Props (structural, not modulation targets)
648///
649/// | Key            | Type   | Default    | Notes                          |
650/// |----------------|--------|------------|--------------------------------|
651/// | `key`          | string | —          | Optional stable node identity  |
652/// | `maxDelayMs`   | number | 1000       | Maximum delay buffer length    |
653/// | `transitionMs` | number | 100        | Crossfade length in ms         |
654/// | `bigLeapMode`  | string | "linear"   | "linear" or "step"             |
655///
656/// # Children layout
657///
658/// `[delay_ms, fb, audio_input]`
659///
660/// # Example
661///
662/// ```ignore
663/// use elemaudio_rs::{Graph, el, extra};
664/// use serde_json::json;
665///
666/// let delayed = extra::stride_delay(
667///     json!({ "maxDelayMs": 1500, "transitionMs": 60 }),
668///     el::const_with_key("delay", 250.0),   // delay_ms signal
669///     el::const_with_key("fb", 0.3),         // fb signal
670///     el::r#in(json!({"channel": 0}), None), // audio input
671/// );
672/// ```
673pub fn stride_delay(
674    props: serde_json::Value,
675    delay_ms: impl Into<ElemNode>,
676    fb: impl Into<ElemNode>,
677    x: impl Into<ElemNode>,
678) -> Node {
679    let resolved_props = stride_delay_resolve_defaults(props);
680    Node::new(
681        "stridedelay",
682        resolved_props,
683        vec![resolve(delay_ms), resolve(fb), resolve(x)],
684    )
685}
686
687/// Stride-interpolated delay helper (stereo).
688///
689/// Same props as [`stride_delay`]; takes two audio inputs and returns
690/// two output nodes via `unpack`.
691pub fn stereo_stride_delay(
692    props: serde_json::Value,
693    delay_ms: impl Into<ElemNode>,
694    fb: impl Into<ElemNode>,
695    left: impl Into<ElemNode>,
696    right: impl Into<ElemNode>,
697) -> Vec<Node> {
698    let resolved_props = stride_delay_resolve_defaults(props);
699    unpack(
700        Node::new(
701            "stridedelay",
702            resolved_props,
703            vec![
704                resolve(delay_ms),
705                resolve(fb),
706                resolve(left),
707                resolve(right),
708            ],
709        ),
710        2,
711    )
712}
713
714/// Apply defaults for stride_delay props.
715fn stride_delay_resolve_defaults(props: serde_json::Value) -> serde_json::Value {
716    let mut map = match props {
717        serde_json::Value::Object(m) => m,
718        _ => serde_json::Map::new(),
719    };
720
721    map.entry("maxDelayMs")
722        .or_insert_with(|| serde_json::Value::from(1000.0));
723    map.entry("transitionMs")
724        .or_insert_with(|| serde_json::Value::from(100.0));
725    map.entry("bigLeapMode")
726        .or_insert_with(|| serde_json::Value::from("linear"));
727
728    serde_json::Value::Object(map)
729}