insta_fun/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::fmt::Write;
4
5use fundsp::prelude::*;
6
7mod macros;
8
9const DEFAULT_HEIGHT: usize = 100;
10
11#[derive(Debug, Clone, Copy)]
12/// Configuration for snapshotting an audio node.
13pub struct SnapshotConfig {
14    /// Number of samples to generate.
15    ///
16    /// `Default` is 44100 - 1s
17    pub num_samples: usize,
18    /// Sample rate of the audio node.
19    ///
20    /// `Default` is 44100.0
21    pub sample_rate: f64,
22    /// Optional width of the SVG `viewBox`
23    ///
24    /// `None` means proportional to num_samples
25    pub svg_width: Option<usize>,
26    /// Height of **one** channel in the SVG `viewBox`
27    ///
28    /// `None` fallbacks to default - 100
29    pub svg_height_per_channel: Option<usize>,
30    /// Processing mode for snapshotting an audio node.
31    pub processing_mode: Processing,
32    /// Whether to include inputs in snapshot
33    pub with_inputs: bool,
34}
35
36/// Processing mode for snapshotting an audio node.
37#[derive(Debug, Clone, Copy, Default)]
38pub enum Processing {
39    #[default]
40    /// Process one sample at a time.
41    Tick,
42    /// Process a batch of samples at a time.
43    ///
44    /// max batch size is 64
45    Batch(u8),
46}
47
48impl Default for SnapshotConfig {
49    fn default() -> Self {
50        Self {
51            num_samples: 44100,
52            sample_rate: 44100.0,
53            svg_width: None,
54            svg_height_per_channel: Some(DEFAULT_HEIGHT),
55            processing_mode: Processing::default(),
56            with_inputs: false,
57        }
58    }
59}
60
61impl SnapshotConfig {
62    pub fn with_samples(num_samples: usize) -> Self {
63        Self {
64            num_samples,
65            ..Default::default()
66        }
67    }
68}
69
70/// Input provided to the audio node
71pub enum InputSource {
72    /// No input
73    None,
74    /// Input provided by a channel vec
75    ///
76    /// - First vec contains all **channels**
77    /// - Second vec contains **samples** per channel
78    VecByChannel(Vec<Vec<f32>>),
79    /// Input provided by a tick vec
80    ///
81    /// - First vec contains all **ticks**
82    /// - Second vec contains **samples** for all **channels** per tick
83    VecByTick(Vec<Vec<f32>>),
84    /// Input **repeated** on every tick
85    ///
86    /// - Vector contains **samples** for all **channels** for **one** tick
87    Flat(Vec<f32>),
88    /// Input provided by a generator function
89    ///
90    /// - First argument is the sample index
91    /// - Second argument is the channel index
92    Generator(Box<dyn Fn(usize, usize) -> f32>),
93}
94
95impl InputSource {
96    pub fn impulse() -> Self {
97        Self::Generator(Box::new(|i, _| if i == 0 { 1.0 } else { 0.0 }))
98    }
99    pub fn sine(freq: f32, sample_rate: f32) -> Self {
100        Self::Generator(Box::new(move |i, _| {
101            let phase = 2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate;
102            phase.sin()
103        }))
104    }
105}
106
107const OUTPUT_CHANNEL_COLORS: &[&str] = &[
108    "#4285F4", "#EA4335", "#FBBC04", "#34A853", "#FF6D00", "#AB47BC", "#00ACC1", "#7CB342",
109    "#9C27B0", "#3F51B5", "#009688", "#8BC34A", "#FFEB3B", "#FF9800", "#795548", "#607D8B",
110    "#E91E63", "#673AB7", "#2196F3", "#00BCD4", "#4CAF50", "#CDDC39", "#FFC107", "#FF5722",
111    "#9E9E9E", "#03A9F4", "#8D6E63", "#78909C", "#880E4F", "#4A148C", "#0D47A1", "#004D40",
112];
113
114const INPUT_CHANNEL_COLORS: &[&str] = &[
115    "#B39DDB", "#FFAB91", "#FFF59D", "#A5D6A7", "#FFCC80", "#CE93D8", "#80DEEA", "#C5E1A5",
116    "#BA68C8", "#9FA8DA", "#80CBC4", "#DCE775", "#FFF176", "#FFB74D", "#BCAAA4", "#B0BEC5",
117    "#F48FB1", "#B39DDB", "#90CAF9", "#80DEEA", "#A5D6A7", "#E6EE9C", "#FFD54F", "#FF8A65",
118    "#BDBDBD", "#81D4FA", "#A1887F", "#90A4AE", "#C2185B", "#7B1FA2", "#1976D2", "#00796B",
119];
120
121const PADDING: isize = 10;
122
123/// Create an SVG snapshot of audio node outputs
124/// ## Example
125///
126/// ```
127/// use insta_fun::*;
128/// use fundsp::hacker::prelude::*;
129///
130/// let node = sine_hz::<f32>(440.0);
131/// let svg = snapshot_audio_node(node);
132/// println!("{svg}");
133/// ```
134pub fn snapshot_audio_node<N>(node: N) -> String
135where
136    N: AudioUnit,
137{
138    snapshot_audio_node_with_input_and_options(node, InputSource::None, SnapshotConfig::default())
139}
140
141/// Create an SVG snapshot of audio node outputs, with options
142///
143/// ## Example
144///
145/// ```
146/// use insta_fun::*;
147/// use fundsp::hacker::prelude::*;
148///
149/// let node = sine_hz::<f32>(440.0);
150/// let svg = snapshot_audio_node_with_options(node, SnapshotConfig::default());
151/// println!("{svg}");
152/// ```
153pub fn snapshot_audio_node_with_options<N>(node: N, options: SnapshotConfig) -> String
154where
155    N: AudioUnit,
156{
157    snapshot_audio_node_with_input_and_options(node, InputSource::None, options)
158}
159
160/// Create an SVG snapshot of audio node inputs and outputs
161///
162/// ## Example
163///
164/// ```
165/// use insta_fun::*;
166/// use fundsp::hacker::prelude::*;
167///
168/// let node = sine_hz::<f32>(440.0);
169/// let svg = snapshot_audio_node_with_input(node, InputSource::None);
170/// println!("{svg}");
171/// ```
172pub fn snapshot_audio_node_with_input<N>(node: N, input_source: InputSource) -> String
173where
174    N: AudioUnit,
175{
176    snapshot_audio_node_with_input_and_options(
177        node,
178        input_source,
179        SnapshotConfig {
180            with_inputs: true,
181            ..SnapshotConfig::default()
182        },
183    )
184}
185
186/// Create an SVG snapshot of audio node inputs and outputs, with options
187///
188/// ## Example
189///
190/// ```
191/// use insta_fun::*;
192/// use fundsp::hacker::prelude::*;
193///
194/// let config = SnapshotConfig::default();
195/// let node = sine_hz::<f32>(440.0);
196/// let svg = snapshot_audio_node_with_input_and_options(node, InputSource::None, config);
197/// println!("{svg}");
198/// ```
199pub fn snapshot_audio_node_with_input_and_options<N>(
200    mut node: N,
201    input_source: InputSource,
202    config: SnapshotConfig,
203) -> String
204where
205    N: AudioUnit,
206{
207    let num_inputs = N::inputs(&node);
208    let num_outputs = N::outputs(&node);
209
210    node.set_sample_rate(config.sample_rate);
211    node.reset();
212    node.allocate();
213
214    let input_data = match input_source {
215        InputSource::None => vec![vec![0.0; config.num_samples]; num_inputs],
216        InputSource::VecByChannel(data) => {
217            assert_eq!(
218                data.len(),
219                num_inputs,
220                "Input vec size mismatch. Expected {} channels, got {}",
221                num_inputs,
222                data.len()
223            );
224            assert!(
225                data.iter().all(|v| v.len() == config.num_samples),
226                "Input vec size mismatch. Expected {} samples per channel, got {}",
227                config.num_samples,
228                data.iter().map(|v| v.len()).max().unwrap_or(0)
229            );
230            data
231        }
232        InputSource::VecByTick(data) => {
233            assert!(
234                data.iter().all(|v| v.len() == num_inputs),
235                "Input vec size mismatch. Expected {} channels, got {}",
236                num_inputs,
237                data.iter().map(|v| v.len()).max().unwrap_or(0)
238            );
239            assert_eq!(
240                data.len(),
241                config.num_samples,
242                "Input vec size mismatch. Expected {} samples, got {}",
243                config.num_samples,
244                data.len()
245            );
246            (0..num_inputs)
247                .map(|ch| (0..config.num_samples).map(|i| data[i][ch]).collect())
248                .collect()
249        }
250        InputSource::Flat(data) => {
251            assert_eq!(
252                data.len(),
253                num_inputs,
254                "Input vec size mismatch. Expected {} channels, got {}",
255                num_inputs,
256                data.len()
257            );
258            (0..num_inputs)
259                .map(|ch| (0..config.num_samples).map(|_| data[ch]).collect())
260                .collect()
261        }
262        InputSource::Generator(generator_fn) => (0..num_inputs)
263            .map(|ch| {
264                (0..config.num_samples)
265                    .map(|i| generator_fn(i, ch))
266                    .collect()
267            })
268            .collect(),
269    };
270
271    let mut output_data: Vec<Vec<f32>> = vec![vec![]; num_outputs];
272
273    match config.processing_mode {
274        Processing::Tick => {
275            (0..config.num_samples).for_each(|i| {
276                let mut input_frame = vec![0.0; num_inputs];
277                for ch in 0..num_inputs {
278                    input_frame[ch] = input_data[ch][i] as f32;
279                }
280                let mut output_frame = vec![0.0; num_outputs];
281                node.tick(&input_frame, &mut output_frame);
282                for ch in 0..num_outputs {
283                    output_data[ch].push(output_frame[ch]);
284                }
285            });
286        }
287        Processing::Batch(batch_size) => {
288            assert!(
289                batch_size <= 64,
290                "Batch size must be less than or equal to 64"
291            );
292
293            let samples_index = (0..config.num_samples).collect::<Vec<_>>();
294            for chunk in samples_index.chunks(batch_size as usize) {
295                let mut input_buff = BufferVec::new(num_inputs);
296                for i in chunk {
297                    for (ch, input_data) in input_data.iter().enumerate() {
298                        let value: f32 = input_data[*i];
299                        input_buff.set_f32(ch, *i, value);
300                    }
301                }
302                let input_ref = input_buff.buffer_ref();
303                let mut output_buf = BufferVec::new(num_outputs);
304                let mut output_ref = output_buf.buffer_mut();
305
306                node.process(chunk.len(), &input_ref, &mut output_ref);
307
308                for (ch, data) in output_data.iter_mut().enumerate() {
309                    data.extend_from_slice(output_buf.channel_f32(ch));
310                }
311            }
312        }
313    }
314
315    generate_svg(&input_data, &output_data, &config)
316}
317
318fn generate_svg(
319    input_data: &[Vec<f32>],
320    output_data: &[Vec<f32>],
321    config: &SnapshotConfig,
322) -> String {
323    let height_per_channel = config.svg_height_per_channel.unwrap_or(DEFAULT_HEIGHT);
324    let num_channels = output_data.len() + {
325        if config.with_inputs {
326            input_data.len()
327        } else {
328            0
329        }
330    };
331    let num_samples = output_data.first().map(|c| c.len()).unwrap_or(0);
332    if num_samples == 0 || num_channels == 0 {
333        return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\" preserveAspectRatio=\"none\"><text>Empty</text></svg>".to_string();
334    }
335
336    let svg_width = config.svg_width.unwrap_or(config.num_samples);
337    let total_height = height_per_channel * num_channels;
338    let y_scale = (height_per_channel as f32 / 2.0) * 0.9;
339    let x_scale = config
340        .svg_width
341        .map(|width| width as f32 / config.num_samples as f32);
342    let stroke_width = if let Some(scale) = x_scale {
343        (2.0 / scale).clamp(0.5, 5.0)
344    } else {
345        2.0
346    };
347
348    let mut svg = String::new();
349    let mut y_offset = 0;
350
351    writeln!(
352        &mut svg,
353        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{start_x} {start_y} {width} {height}" preserveAspectRatio="none">
354        <rect x="{start_x}" y="{start_y}" width="{background_width}" height="{background_height}" fill="black" />"#,
355        start_x = -PADDING,
356        start_y = -PADDING,
357        width = svg_width as isize + PADDING,
358        height = total_height as isize + PADDING,
359        background_width = svg_width as isize + PADDING * 2,
360        background_height = total_height as isize + PADDING * 2
361    ).unwrap();
362
363    let mut write_data = |all_channels_data: &[Vec<f32>], is_input: bool| {
364        for (ch, data) in all_channels_data.iter().enumerate() {
365            let color = if is_input {
366                INPUT_CHANNEL_COLORS[ch % INPUT_CHANNEL_COLORS.len()]
367            } else {
368                OUTPUT_CHANNEL_COLORS[ch % OUTPUT_CHANNEL_COLORS.len()]
369            };
370            let y_center = y_offset + height_per_channel / 2;
371
372            let min_val = data.iter().cloned().fold(f32::INFINITY, f32::min);
373            let max_val = data.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
374            let range = (max_val - min_val).max(f32::EPSILON);
375
376            let mut path_data = String::from("M ");
377            for (i, &sample) in data.iter().enumerate() {
378                let x = if let Some(scale) = x_scale {
379                    scale * i as f32
380                } else {
381                    i as f32
382                };
383                let normalized = (sample.clamp(min_val, max_val) - min_val) / range * 2.0 - 1.0;
384                let y = y_center as f32 - normalized * y_scale;
385                if i == 0 {
386                    write!(&mut path_data, "{:.3},{:.3} ", x, y).unwrap();
387                } else {
388                    write!(&mut path_data, "L {:.3},{:.3} ", x, y).unwrap();
389                }
390            }
391
392            writeln!(
393                &mut svg,
394                r#"  <path d="{path_data}" fill="none" stroke="{color}" stroke-width="{stroke_width:.3}"/>"#,
395            )
396            .unwrap();
397
398            writeln!(
399                &mut svg,
400                r#"  <text x="5" y="{y}" font-family="monospace" font-size="12" fill="{color}">{label} Ch#{ch}</text>"#,
401                y = y_offset + 15,
402                color = color,
403                label = if is_input {"Input"} else {"Output"},
404                ch=ch
405            )
406            .unwrap();
407
408            y_offset += height_per_channel
409        }
410    };
411
412    if config.with_inputs {
413        write_data(input_data, true);
414    }
415    write_data(output_data, false);
416
417    svg.push_str("</svg>");
418    svg
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_sine() {
427        let config = SnapshotConfig::default();
428        let node = sine_hz::<f32>(440.0);
429        let svg = snapshot_audio_node_with_input_and_options(node, InputSource::None, config);
430
431        insta::assert_binary_snapshot!("sine.svg", svg.into_bytes())
432    }
433
434    #[test]
435    fn test_custom_input() {
436        let config = SnapshotConfig::with_samples(100);
437        let input = (0..100).map(|i| (i as f32 / 50.0).sin()).collect();
438
439        let svg = snapshot_audio_node_with_input_and_options(
440            lowpass_hz(500.0, 0.7),
441            InputSource::VecByChannel(vec![input]),
442            config,
443        );
444
445        insta::assert_binary_snapshot!("custom_input.svg", svg.into_bytes())
446    }
447
448    #[test]
449    fn test_stereo() {
450        let config = SnapshotConfig::default();
451        let node = sine_hz::<f32>(440.0) | sine_hz::<f32>(880.0);
452
453        let svg = snapshot_audio_node_with_input_and_options(node, InputSource::None, config);
454
455        insta::assert_binary_snapshot!("stereo.svg", svg.into_bytes())
456    }
457
458    #[test]
459    fn test_lowpass_impulse() {
460        let config = SnapshotConfig::with_samples(300);
461        let node = lowpass_hz(1000.0, 1.0);
462
463        let svg = snapshot_audio_node_with_input_and_options(node, InputSource::impulse(), config);
464
465        insta::assert_binary_snapshot!("lowpass_impulse.svg", svg.into_bytes())
466    }
467
468    #[test]
469    fn test_net() {
470        let config = SnapshotConfig::with_samples(420);
471        let node = sine_hz::<f32>(440.0) >> lowpass_hz(500.0, 0.7);
472        let mut net = Net::new(0, 1);
473        let node_id = net.push(Box::new(node));
474        net.pipe_input(node_id);
475        net.pipe_output(node_id);
476
477        let svg = snapshot_audio_node_with_input_and_options(net, InputSource::None, config);
478
479        insta::assert_binary_snapshot!("net.svg", svg.into_bytes())
480    }
481
482    #[test]
483    fn test_batch_prcessing() {
484        let config = SnapshotConfig {
485            processing_mode: Processing::Batch(64),
486            ..Default::default()
487        };
488
489        let node = sine_hz::<f32>(440.0);
490
491        let svg = snapshot_audio_node_with_options(node, config);
492
493        insta::assert_binary_snapshot!("process_64.svg", svg.into_bytes())
494    }
495
496    #[test]
497    fn test_vec_by_tick() {
498        let config = SnapshotConfig::with_samples(100);
499        // Create input data organized by ticks (100 ticks, 1 channel each)
500        let input_data: Vec<Vec<f32>> = (0..100).map(|i| vec![(i as f32 / 50.0).cos()]).collect();
501
502        let svg = snapshot_audio_node_with_input_and_options(
503            lowpass_hz(800.0, 0.5),
504            InputSource::VecByTick(input_data),
505            config,
506        );
507
508        insta::assert_binary_snapshot!("vec_by_tick.svg", svg.into_bytes())
509    }
510
511    #[test]
512    fn test_flat_input() {
513        let config = SnapshotConfig::with_samples(200);
514        // Flat input repeated for every tick
515        let flat_input = vec![0.5];
516
517        let svg = snapshot_audio_node_with_input_and_options(
518            highpass_hz(200.0, 0.7),
519            InputSource::Flat(flat_input),
520            config,
521        );
522
523        insta::assert_binary_snapshot!("flat_input.svg", svg.into_bytes())
524    }
525
526    #[test]
527    fn test_sine_input_source() {
528        let config = SnapshotConfig::with_samples(200);
529
530        let svg = snapshot_audio_node_with_input_and_options(
531            bandpass_hz(1000.0, 500.0),
532            InputSource::sine(100.0, 44100.0),
533            config,
534        );
535
536        insta::assert_binary_snapshot!("sine_input_source.svg", svg.into_bytes())
537    }
538
539    #[test]
540    fn test_multi_channel_vec_by_channel_with_inputs() {
541        let config = SnapshotConfig {
542            with_inputs: true,
543            ..SnapshotConfig::with_samples(150)
544        };
545        // Create stereo input data
546        let left_channel: Vec<f32> = (0..150)
547            .map(|i| (i as f32 / 75.0 * std::f32::consts::PI).sin())
548            .collect();
549        let right_channel: Vec<f32> = (0..150)
550            .map(|i| (i as f32 / 75.0 * std::f32::consts::PI).cos())
551            .collect();
552
553        let node = resonator_hz(440.0, 100.0) | resonator_hz(440.0, 100.0);
554
555        let svg = snapshot_audio_node_with_input_and_options(
556            node,
557            InputSource::VecByChannel(vec![left_channel, right_channel]),
558            config,
559        );
560
561        insta::assert_binary_snapshot!(
562            "multi_channel_vec_by_channel_with_inputs.svg",
563            svg.into_bytes()
564        )
565    }
566
567    #[test]
568    fn test_macros() {
569        let node = sine_hz::<f32>(440.0);
570
571        assert_audio_node_snapshot!("macros", node);
572    }
573}