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}