Render Offline to WAV
Render a patch offline—faster than real-time—and write the result to a
standard .wav file you can play in any audio player. Requires the std
feature (enabled by default).
Quick Version
render_to_wav does everything in one call:
use quiver::prelude::*; // re-exports render and render_to_wav
use std::path::Path;
let mut patch = Patch::new(44100.0);
// ... add modules, connect, set_output ...
// Render 2 seconds and write a 16-bit PCM stereo WAV
render_to_wav(&mut patch, 2.0, Path::new("target/my_patch.wav"))?;
The patch is compiled lazily if needed, so a freshly-built patch renders
without an explicit compile() call.
Rendering to Buffers
render returns raw (left, right) sample buffers so you can analyze or
post-process before writing:
// Number of frames = round(seconds * patch.sample_rate())
let (left, right) = render(&mut patch, 1.0);
let peak = left.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
println!("Generated {} samples, peak {:.2}V", left.len(), peak);
Rendering drives the same per-sample engine as patch.tick() with no
per-frame allocation—the samples are identical to ticking one sample at a
time.
Writing Buffers with write_wav
write_wav writes any pair of channel buffers (not in the prelude—import it
from quiver::render):
use quiver::render::write_wav;
write_wav(Path::new("target/out.wav"), 44100, &left, &right)?;
If the channel lengths differ, the shorter length is used. The output is always 16-bit PCM stereo with the sample rate you pass.
Watch the Sample Scale
WAV samples are full-scale [-1.0, 1.0], but Quiver’s Audio ports follow
the modular-synth ±5V convention. Both render_to_wav and write_wav treat
the buffers as full-scale and clamp anything outside ±1.0—so a raw ±5V
signal will clip hard at full scale.
Scale down before writing, either in the patch (e.g. through a Vca or
Attenuverter) or on the rendered buffers:
// ±5V modular convention -> ±1.0 full scale
let to_full_scale = |buf: &[f64]| -> Vec<f64> { buf.iter().map(|s| s / 5.0).collect() };
write_wav(path, 44100, &to_full_scale(&left), &to_full_scale(&right))?;
Resonant filters can briefly overshoot 5V on sharp attacks; divide by a little more (e.g. 6.0) to leave headroom. There is no automatic normalization—what you pass is what gets written.
Sequencing While Rendering
Because render advances the patch in-place, you can call it repeatedly
while changing control values between calls—for example driving pitch and
gate via ExternalInput:
for ¬e in &[48u8, 52, 55, 60] {
pitch_cv.set((note as f64 - 60.0) / 12.0);
gate_cv.set(5.0);
let (l, r) = render(&mut patch, 0.2); // note on
left_all.extend(l);
right_all.extend(r);
gate_cv.set(0.0);
let (l, r) = render(&mut patch, 0.05); // release tail
left_all.extend(l);
right_all.extend(r);
}
write_wav(path, 44100, &left_all, &right_all)?;
Complete Example
The render_wav example renders a sequenced arpeggio through a resonant
filter to target/render_wav.wav. Run it with
cargo run --example render_wav:
//! Render a Musical Phrase to WAV
//!
//! This is the "hear Quiver make sound" flagship example: a short sequenced
//! arpeggio through a resonant filter, shaped by an envelope, rendered
//! offline to a real `.wav` file you can play in any audio player.
//!
//! # Why this patch sounds the way it does
//!
//! - **V/Oct pitch**: the VCO's `voct` input follows the 1-volt-per-octave
//! convention used by real modular synths — each additional volt doubles
//! the oscillator frequency, and each `1/12`V step is one semitone. That's
//! why converting a MIDI note to a control voltage is just
//! `(note - 60) / 12.0` (MIDI note 60 = middle C = 0V here).
//! - **Envelope-to-filter modulation**: the same ADSR signal that shapes the
//! VCA's amplitude also drives the filter's cutoff. This is the classic
//! "plucked" synth-bass trick: the filter snaps open on the attack (bright
//! pluck) and closes again as the envelope decays (a duller sustain/tail),
//! all from one modulation source instead of two.
//! - **Gate vs. trigger timing**: each note holds its gate high for only
//! 80% of its slot before releasing, leaving an audible gap before the
//! next note's attack — otherwise back-to-back notes at full sustain would
//! blur together with no perceptible attack transient.
//!
//! Run with: cargo run --example render_wav
use quiver::prelude::*;
use quiver::render::write_wav;
use std::path::Path;
use std::sync::Arc;
/// Convert a MIDI note number to a V/Oct control voltage (0V = MIDI 60 / C4).
fn midi_to_voct(note: u8) -> f64 {
(note as f64 - 60.0) / 12.0
}
fn main() {
let sample_rate = 44100.0;
let mut patch = Patch::new(sample_rate);
// External inputs stand in for a sequencer: we drive pitch and gate by
// hand, one note at a time, in the loop below.
let pitch_cv = Arc::new(AtomicF64::new(0.0));
let gate_cv = Arc::new(AtomicF64::new(0.0));
let pitch = patch.add("pitch", ExternalInput::voct(Arc::clone(&pitch_cv)));
let gate = patch.add("gate", ExternalInput::gate(Arc::clone(&gate_cv)));
// Voice: VCO -> VCF -> VCA, the same subtractive-synthesis chain as
// first_patch.rs, but with the envelope also opening/closing the filter.
let vco = patch.add("vco", Vco::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());
patch.connect(pitch.out("out"), vco.in_("voct")).unwrap();
patch.connect(gate.out("out"), env.in_("gate")).unwrap();
patch.connect(vco.out("saw"), vcf.in_("in")).unwrap();
patch.connect(vcf.out("lp"), vca.in_("in")).unwrap();
patch.connect(vca.out("out"), output.in_("left")).unwrap();
patch.connect(vca.out("out"), output.in_("right")).unwrap();
// One envelope, two destinations: amplitude AND filter brightness.
patch.connect(env.out("env"), vca.in_("cv")).unwrap();
patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
patch.set_output(output.id());
patch.compile().unwrap();
// A one-bar arpeggio: C3 - E3 - G3 - C4, played twice.
let phrase = [48u8, 52, 55, 60, 48, 52, 55, 60];
let note_seconds = 0.25;
// Hold the gate for 80% of each slot so the release tail is audible
// before the next note's attack (see the module doc above).
let gate_high_seconds = note_seconds * 0.8;
let gate_low_seconds = note_seconds - gate_high_seconds;
println!("=== Render to WAV: Arpeggio Phrase ===\n");
let mut left_all = Vec::new();
let mut right_all = Vec::new();
for (i, ¬e) in phrase.iter().enumerate() {
let voct = midi_to_voct(note);
println!("Step {}: MIDI {note} ({voct:.3}V)", i + 1);
// Gate on: set the pitch and open the gate, then render the "on"
// portion of this note's slot.
pitch_cv.set(voct);
gate_cv.set(5.0);
let (l, r) = render(&mut patch, gate_high_seconds);
left_all.extend(l);
right_all.extend(r);
// Gate off: release the envelope for the remainder of the slot.
gate_cv.set(0.0);
let (l, r) = render(&mut patch, gate_low_seconds);
left_all.extend(l);
right_all.extend(r);
}
let peak = left_all.iter().map(|s| s.abs()).fold(0.0_f64, f64::max);
println!(
"\nGenerated {} samples ({:.2}s)",
left_all.len(),
left_all.len() as f64 / sample_rate
);
println!("Peak amplitude: {peak:.2}V");
// Quiver's Audio ports use a +-5V modular convention, but a .wav file's
// samples are full-scale +-1.0, so scale down before writing (see the
// `# Sample scale` note on `quiver::render`). We divide by 6 rather than
// 5 to leave a little headroom for the resonant filter's brief overshoot
// above 5V on sharp attack transients, so the WAV doesn't clip.
let to_full_scale = |buf: &[f64]| -> Vec<f64> { buf.iter().map(|s| s / 6.0).collect() };
let path = Path::new("target/render_wav.wav");
write_wav(
path,
sample_rate as u32,
&to_full_scale(&left_all),
&to_full_scale(&right_all),
)
.expect("failed to write WAV file");
println!(
"\nWrote {} - play it in any audio player to hear Quiver make sound!",
path.display()
);
}
For the minimal patch-to-WAV workflow, see quick_taste
(cargo run --example quick_taste), which writes target/quick_taste.wav.