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