Visualize Your Patch
Quiver provides tools to visualize patch topology and analyze signals. These
are standalone helpers, not graph modules—you feed them samples from
patch.tick() rather than patching them into the graph.
DOT/GraphViz Export
Generate visual diagrams of your patch:
use quiver::prelude::*;
let patch = /* your patch */;
// Export with the default (dark) style
let dot = DotExporter::export_default(&patch);
println!("{}", dot);
// Or pass an explicit style
let style = DotStyle::default();
let dot = DotExporter::export(&patch, &style);
Save to file and render:
# Save DOT output
cargo run > patch.dot
# Render with GraphViz
dot -Tpng patch.dot -o patch.png
dot -Tsvg patch.dot -o patch.svg
Styling Options
DotStyle has preset constructors and a couple of builder methods:
// Presets: default() is a dark theme
let style = DotStyle::light(); // Light theme
let style = DotStyle::minimal(); // No port names, no signal colors
// Builders
let style = DotStyle::default()
.with_rankdir("LR") // LR, TB, BT, RL
.with_node_shape("box");
// All fields are public for full control
let style = DotStyle {
show_port_names: true,
color_by_signal: true, // Color-code edges by signal type
..DotStyle::default()
};
Signal type colors:
- Audio: Blue
- CV: Orange
- Gate/Trigger: Green
- V/Oct: Red
Example Output
flowchart LR
subgraph Oscillators
VCO[VCO]
LFO[LFO]
end
subgraph Processing
VCF[VCF]
VCA[VCA]
end
subgraph Envelope
ADSR[ADSR]
end
VCO -->|saw| VCF
LFO -->|sin| VCF
VCF -->|lp| VCA
ADSR -->|env| VCF
ADSR -->|env| VCA
VCA --> Output
style VCO fill:#4a9eff
style LFO fill:#f9a826
style ADSR fill:#50c878
Oscilloscope
Monitor signals in real-time. Scope::new takes the buffer size in samples;
trigger settings are configured with setters:
let mut scope = Scope::new(1024); // Buffer size in samples
scope.set_trigger_mode(TriggerMode::RisingEdge);
scope.set_trigger_level(0.0);
// In your audio loop
let (left, _right) = patch.tick();
scope.tick(left);
// Get waveform for display
let waveform = scope.buffer_vec(); // Vec<f64>
let points = scope.get_display_data(); // Vec<(x 0.0-1.0, voltage)>
Trigger modes:
Free: Continuous displayRisingEdge: Trigger on positive crossing of the trigger levelFallingEdge: Trigger on negative crossingAnyEdge: Trigger on either crossingSingle: One-shot capture (buffer freezes after trigger)
Spectrum Analyzer
View frequency content. The constructor takes the FFT size (rounded up to a power of two) and the sample rate:
let mut analyzer = SpectrumAnalyzer::new(2048, 44100.0);
analyzer.set_smoothing(0.8); // 0.0 = none, up to 0.99
// Feed samples; the spectrum recomputes each time the buffer fills
for sample in samples.iter() {
analyzer.tick(*sample);
}
// Get (frequency_hz, magnitude_db) pairs
let spectrum = analyzer.get_spectrum();
// Query a specific frequency
let db_at_440 = analyzer.magnitude_at(440.0);
// Find dominant frequency
let peak_freq = analyzer.peak_frequency();
println!("Fundamental: {:.1} Hz", peak_freq);
Level Meter
Monitor audio levels. All readings are in dB; peak hold defaults to 1.5 seconds and is adjusted with a setter:
let mut meter = LevelMeter::new(44100.0);
meter.set_peak_hold_time(0.5, 44100.0); // 500ms peak hold
// Process samples
for sample in samples.iter() {
meter.tick(*sample);
}
println!("RMS: {:.1} dB", meter.rms());
println!("Peak: {:.1} dB", meter.peak());
println!("Peak hold: {:.1} dB", meter.peak_hold());
if meter.is_clipping() {
println!("Clipping!");
}
Automation Recording
Record parameter changes over time. The recorder samples parameter values at a configurable interval via a closure; times are in samples:
let mut recorder = AutomationRecorder::new(44100.0);
recorder.set_interval(441); // Sample every 441 ticks (10ms)
recorder.add_track("filter_cutoff");
recorder.start();
// In your audio loop: the closure supplies the current value per track
for _ in 0..44100 {
patch.tick();
recorder.tick(|param_id| match param_id {
"filter_cutoff" => Some(current_cutoff),
_ => None,
});
}
recorder.stop();
// Inspect or export
if let Some(track) = recorder.get_track("filter_cutoff") {
println!("Duration: {:.2}s", track.duration_seconds());
let value = track.value_at(22050); // Interpolated value at sample 22050
}
let data = recorder.export(); // AutomationData (serde-serializable)
let json = serde_json::to_string(&data)?;
You can also build tracks by hand with AutomationTrack::new(param_id, sample_rate) and track.record(time_in_samples, value), then thin dense data
with simplify(tolerance).
Example: Complete Visualization
//! How-To: Visualize Your Patch
//!
//! Demonstrates patch visualization including DOT export,
//! signal analysis, and metering.
//!
//! Run with: cargo run --example howto_visualization
use quiver::prelude::*;
fn main() {
let sample_rate = 44100.0;
println!("=== Patch Visualization Demo ===\n");
// Build a patch to visualize
let mut patch = Patch::new(sample_rate);
let vco = patch.add("vco", Vco::new(sample_rate));
let lfo = patch.add("lfo", Lfo::new(sample_rate));
let vcf = patch.add("vcf", Svf::new(sample_rate));
let vca = patch.add("vca", Vca::new());
let env = patch.add("env", Adsr::new(sample_rate));
let output = patch.add("output", StereoOutput::new());
// Connections
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(lfo.out("sin"), vcf.in_("fm")).unwrap();
patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.connect(vca.out("out"), output.in_("right")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
// Generate DOT visualization
println!("--- DOT Graph Output ---");
println!("(Save this to a .dot file and render with GraphViz)\n");
let style = DotStyle::default();
let dot = DotExporter::export(&patch, &style);
println!("{}", dot);
// Generate audio for analysis
println!("\n--- Signal Analysis ---\n");
// Collect samples
let num_samples = (sample_rate * 0.5) as usize;
let mut samples = Vec::with_capacity(num_samples);
for _ in 0..num_samples {
let (left, _) = patch.tick();
samples.push(left);
}
// Basic statistics
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
let rms = (samples.iter().map(|s| s * s).sum::<f64>() / num_samples as f64).sqrt();
let dc_offset = samples.iter().sum::<f64>() / num_samples as f64;
println!("Sample Statistics:");
println!(" Samples: {}", num_samples);
println!(
" Peak: {:.3}V ({:.1} dB)",
peak,
20.0 * (peak / 5.0).log10()
);
println!(" RMS: {:.3}V ({:.1} dB)", rms, 20.0 * (rms / 5.0).log10());
println!(" DC Offset: {:.6}V", dc_offset);
// Estimate frequency via zero crossings
let mut zero_crossings = 0;
for i in 1..samples.len() {
if (samples[i] >= 0.0) != (samples[i - 1] >= 0.0) {
zero_crossings += 1;
}
}
let estimated_freq = zero_crossings as f64 / 2.0 / (num_samples as f64 / sample_rate);
println!(" Estimated Frequency: {:.1} Hz", estimated_freq);
// ASCII waveform visualization
println!("\n--- Waveform (ASCII) ---\n");
let display_samples = 80; // Characters wide
let step = samples.len() / display_samples;
for row in (0..11).rev() {
let threshold = (row as f64 - 5.0) / 5.0 * peak;
let mut line = String::new();
for col in 0..display_samples {
let sample = samples[col * step];
if (sample >= threshold && row > 5) || (sample <= threshold && row < 5) {
line.push('█');
} else if row == 5 {
line.push('─');
} else {
line.push(' ');
}
}
let label = match row {
10 => "+peak",
5 => " 0V ",
0 => "-peak",
_ => " ",
};
println!("{} |{}", label, line);
}
// Using the Scope module
println!("\n--- Scope Analysis ---\n");
let mut scope = Scope::new(1024); // Buffer size in samples
// Recreate patch for fresh samples
patch.compile().unwrap();
// Fill scope buffer
for _ in 0..1024 {
let (left, _) = patch.tick();
scope.tick(left);
}
let buffer = scope.buffer_vec();
println!("Scope buffer size: {} samples", buffer.len());
// Using LevelMeter
println!("\n--- Level Meter ---\n");
let mut meter = LevelMeter::new(sample_rate);
for _ in 0..(sample_rate * 0.1) as usize {
let (left, _) = patch.tick();
meter.tick(left);
}
println!("Level Meter:");
println!(" RMS Level: {:.2} dB", meter.rms());
println!(" Peak Level: {:.2} dB", meter.peak());
// Module graph summary
println!("\n--- Patch Summary ---\n");
println!("Modules: {}", patch.node_count());
println!("Cables: {}", patch.cable_count());
println!("\nTo visualize graphically:");
println!(" 1. Save the DOT output above to 'patch.dot'");
println!(" 2. Run: dot -Tpng patch.dot -o patch.png");
println!(" 3. Open patch.png in an image viewer");
}
Integration with GUIs
The visualization data is designed for easy GUI integration:
// For immediate-mode GUIs (egui, imgui)
for (freq, magnitude_db) in analyzer.get_spectrum() {
draw_bar(freq, magnitude_db);
}
// For retained-mode GUIs
let path: Vec<(f64, f64)> = scope.get_display_data();
draw_path(&path);