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,
34 pub with_inputs: bool,
38}
39
40#[derive(Debug, Clone, Copy, Default)]
42pub enum Processing {
43 #[default]
44 Tick,
46 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
74pub enum InputSource {
76 None,
78 VecByChannel(Vec<Vec<f32>>),
83 VecByTick(Vec<Vec<f32>>),
88 Flat(Vec<f32>),
92 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
127pub 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
145pub 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
164pub 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
190pub 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 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 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 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}