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)]
12pub struct SnapshotConfig {
14 pub num_samples: usize,
18 pub sample_rate: f64,
22 pub svg_width: Option<usize>,
26 pub svg_height_per_channel: Option<usize>,
30 pub processing_mode: Processing,
32 pub with_inputs: bool,
34}
35
36#[derive(Debug, Clone, Copy, Default)]
38pub enum Processing {
39 #[default]
40 Tick,
42 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
70pub enum InputSource {
72 None,
74 VecByChannel(Vec<Vec<f32>>),
79 VecByTick(Vec<Vec<f32>>),
84 Flat(Vec<f32>),
88 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
123pub 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
141pub 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
160pub 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
186pub 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 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 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 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}