Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 display
  • RisingEdge: Trigger on positive crossing of the trigger level
  • FallingEdge: Trigger on negative crossing
  • AnyEdge: Trigger on either crossing
  • Single: 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);