Polyphonic Patches
So far we’ve built monophonic (single-voice) patches. Real keyboards need polyphony—multiple simultaneous notes. Quiver provides a complete voice allocation system.
flowchart TB
MIDI[MIDI Input] --> VA[Voice<br/>Allocator]
VA --> V1[Voice 1]
VA --> V2[Voice 2]
VA --> V3[Voice 3]
VA --> VN[Voice N]
V1 --> MIX[Mixer]
V2 --> MIX
V3 --> MIX
VN --> MIX
MIX --> OUT[Output]
Voice Allocation
When a new note arrives and all voices are busy, which voice should be “stolen”?
| Strategy | Description | Best For |
|---|---|---|
| RoundRobin | Steal oldest voice | Even wear |
| QuietestSteal | Steal softest voice | Minimal artifacts |
| OldestSteal | Steal note held longest | Predictable |
| NoSteal | Ignore new notes | Pad sounds |
| HighestPriority | High notes steal low | Melodies |
| LowestPriority | Low notes steal high | Bass lines |
Voice States
Each voice has a lifecycle:
stateDiagram-v2
[*] --> Free
Free --> Active: Note On
Active --> Releasing: Note Off
Releasing --> Free: Release Complete
Active --> Active: Retrigger
Releasing --> Active: Retrigger
Building a Polyphonic Patch
//! Tutorial: Polyphonic Patches
//!
//! Demonstrates voice allocation for playing multiple simultaneous notes.
//! This is essential for keyboard-style synthesizers.
//!
//! # Why voice allocation is its own problem
//!
//! A single VCO/VCF/VCA chain can only play one note at a time. Polyphony
//! means running several of those chains ("voices") in parallel and
//! deciding, for each incoming note, *which* voice plays it:
//! - With fewer voices than notes played at once, something has to give —
//! that's what `AllocationMode` governs (steal the oldest note? the
//! quietest one? refuse the new note entirely?). Real keyboards make this
//! same tradeoff; even 16-32 voices can be exhausted by a sustain pedal
//! held through a fast run.
//! - Mixing N simultaneous voices multiplies their combined peak amplitude
//! roughly by N if they're in phase, so naively summing voices can clip.
//! `PolyPatch` applies gain compensation (dividing by roughly `sqrt(N)`,
//! matching how uncorrelated signals combine in power rather than
//! amplitude) so a 4-note chord doesn't come out 4x louder than one note.
//! - `PolyPatch::with_voice_fn` builds one identical copy of your voice
//! graph per voice and wires a per-voice controller into it exposing
//! `voct`/`gate`/`trigger`/`velocity` — the same four signals a
//! monophonic patch would drive by hand (see `tutorial_envelope.rs`),
//! just supplied automatically by the allocator instead of external inputs
//! you manage yourself.
//!
//! Run with: cargo run --example tutorial_polyphony
use quiver::prelude::*;
fn main() {
let num_voices = 4;
println!("=== Polyphony Demo ===\n");
println!("Simulating a {}-voice polyphonic synthesizer\n", num_voices);
// Create a voice allocator
let mut allocator = VoiceAllocator::new(num_voices);
// Helper to convert MIDI note to V/Oct
fn midi_to_voct(note: u8) -> f64 {
(note as f64 - 60.0) / 12.0
}
fn note_name(note: u8) -> String {
let names = [
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
];
let octave = (note / 12) as i32 - 1;
format!("{}{}", names[(note % 12) as usize], octave)
}
// Simulate playing a chord: C4, E4, G4, B4 (Cmaj7)
let chord = [60u8, 64, 67, 71]; // C4, E4, G4, B4
println!("Playing Cmaj7 chord:");
for ¬e in &chord {
if let Some(voice_idx) = allocator.note_on(note, 0.8) {
println!(
" {} (MIDI {}) → Voice {}, V/Oct = {:.3}V",
note_name(note),
note,
voice_idx,
midi_to_voct(note)
);
} else {
println!(
" {} (MIDI {}) → No voice available!",
note_name(note),
note
);
}
}
// Show voice states
println!("\nVoice states after chord:");
for i in 0..num_voices {
if let Some(voice) = allocator.voice(i) {
match voice.state {
VoiceState::Active => {
if let Some(note) = voice.note {
println!(
" Voice {}: Active, playing {} (V/Oct: {:.3}V)",
i,
note_name(note),
voice.voct
);
}
}
VoiceState::Free => println!(" Voice {}: Free", i),
VoiceState::Releasing => println!(" Voice {}: Releasing", i),
}
}
}
// Now try to play another note - will steal!
println!("\nPlaying D5 (MIDI 74) - all voices busy, must steal:");
if let Some(stolen_voice) = allocator.note_on(74, 0.9) {
println!(
" D5 assigned to Voice {} (stolen from previous note)",
stolen_voice
);
} else {
println!(" D5 could not be allocated (NoSteal mode)");
}
// Show updated states
println!("\nVoice states after steal:");
for i in 0..num_voices {
if let Some(voice) = allocator.voice(i) {
match voice.state {
VoiceState::Active => {
if let Some(note) = voice.note {
println!(
" Voice {}: Active, playing {} (V/Oct: {:.3}V)",
i,
note_name(note),
voice.voct
);
}
}
VoiceState::Free => println!(" Voice {}: Free", i),
VoiceState::Releasing => println!(" Voice {}: Releasing", i),
}
}
}
// Release some notes
println!("\nReleasing E4 and G4:");
allocator.note_off(64); // E4
allocator.note_off(67); // G4
println!("\nVoice states after release:");
for i in 0..num_voices {
if let Some(voice) = allocator.voice(i) {
match voice.state {
VoiceState::Active => {
if let Some(note) = voice.note {
println!(" Voice {}: Active, {}", i, note_name(note));
}
}
VoiceState::Free => println!(" Voice {}: Free", i),
VoiceState::Releasing => {
if let Some(note) = voice.note {
println!(" Voice {}: Releasing (was {})", i, note_name(note));
}
}
}
}
}
// Demonstrate different allocation modes
println!("\n--- Allocation Modes ---\n");
for mode in [
AllocationMode::RoundRobin,
AllocationMode::QuietestSteal,
AllocationMode::OldestSteal,
AllocationMode::NoSteal,
AllocationMode::HighestPriority,
AllocationMode::LowestPriority,
] {
let mode_name = match mode {
AllocationMode::RoundRobin => "RoundRobin",
AllocationMode::QuietestSteal => "QuietestSteal",
AllocationMode::OldestSteal => "OldestSteal",
AllocationMode::NoSteal => "NoSteal",
AllocationMode::HighestPriority => "HighestPriority",
AllocationMode::LowestPriority => "LowestPriority",
};
let desc = match mode {
AllocationMode::RoundRobin => "Cycles through voices in order",
AllocationMode::QuietestSteal => "Steals the voice with lowest envelope",
AllocationMode::OldestSteal => "Steals the note held longest",
AllocationMode::NoSteal => "Ignores new notes when full",
AllocationMode::HighestPriority => "Higher notes can steal lower",
AllocationMode::LowestPriority => "Lower notes can steal higher",
};
println!("{}: {}", mode_name, desc);
}
// ------------------------------------------------------------------
// Building an ACTUAL polyphonic synthesizer with PolyPatch
// ------------------------------------------------------------------
// PolyPatch inserts an in-graph voice controller into each voice, so the
// allocator's per-voice pitch/gate signals actually reach real DSP. Here
// each voice is: voice controller -> Vco -> Vca (shaped by an Adsr) -> out.
println!("\n--- Audible PolyPatch (4 voices) ---\n");
let sample_rate = 48_000.0;
let mut synth = PolyPatch::with_voice_fn(num_voices, sample_rate, |patch, ctrl| {
let sr = patch.sample_rate();
let vco = patch.add("vco", Vco::new(sr));
let adsr = patch.add("adsr", Adsr::new(sr));
let vca = patch.add("vca", Vca::new());
let out = patch.add("out", StereoOutput::new());
// The controller exposes voct / gate / trigger / velocity outputs.
patch.connect(ctrl.out("voct"), vco.in_("voct"))?;
patch.connect(ctrl.out("gate"), adsr.in_("gate"))?;
patch.connect(vco.out("saw"), vca.in_("in"))?;
patch.connect(adsr.out("env"), vca.in_("cv"))?;
patch.connect(vca.out("out"), out.in_("left"))?;
patch.set_output(out.id());
Ok(())
})
.expect("failed to build voice graph");
// Play the Cmaj7 chord. Voices output modular-level (~±5 V) audio.
for ¬e in &chord {
synth.note_on(note, 100);
}
// Let the envelopes settle past the onset transient, then measure the
// gain-compensated steady-state peak (1/sqrt(N) keeps chords from clipping).
for _ in 0..(sample_rate as usize / 10) {
synth.tick();
}
let mut peak = 0.0f64;
for _ in 0..(sample_rate as usize / 10) {
let (l, r) = synth.tick();
peak = peak.max(l.abs()).max(r.abs());
}
println!(
" {} voices sounding; gain-compensated steady peak = {:.3} (audible, bounded)",
synth.allocator().active_count(),
peak
);
// Release the chord: the ADSR release tails complete before voices free,
// and the summed output is gain-compensated so chords do not clip.
synth.all_notes_off();
for _ in 0..(sample_rate as usize / 4) {
synth.tick();
}
println!(
" After release: {} voices still freeing (tails complete, not truncated).",
synth.allocator().active_count()
);
println!("\nPolyphony enables expressive keyboard playing and chord voicings.");
}
The PolyPatch API
PolyPatch::with_voice_fn(voices, sample_rate, build) builds one voice graph per voice by
calling your closure. The closure receives a fresh Patch and a voice controller
(ctrl) whose outputs — voct, gate, trigger, and velocity — carry the allocator’s
per-voice control values into the graph:
use quiver::prelude::*;
let sr = 48_000.0;
let mut poly = PolyPatch::with_voice_fn(4, sr, |patch, ctrl| {
let sr = patch.sample_rate();
let vco = patch.add("vco", Vco::new(sr));
let adsr = patch.add("adsr", Adsr::new(sr));
let vca = patch.add("vca", Vca::new());
let out = patch.add("out", StereoOutput::new());
// The controller exposes voct / gate / trigger / velocity.
patch.connect(ctrl.out("voct"), vco.in_("voct"))?;
patch.connect(ctrl.out("gate"), adsr.in_("gate"))?;
patch.connect(vco.out("saw"), vca.in_("in"))?;
patch.connect(adsr.out("env"), vca.in_("cv"))?;
patch.connect(vca.out("out"), out.in_("left"))?;
patch.set_output(out.id());
Ok(())
})
.unwrap();
poly.note_on(60, 100); // MIDI note, velocity (0-127)
let (_l, _r) = poly.tick();
poly.note_off(60);
What PolyPatch handles for you:
- Automatic voice freeing: each voice’s real output level is tracked by an amplitude
follower, so a voice returns to
Freeonly once its release tail has actually decayed — not the instant the gate falls. - Releasing-first voice stealing: when all voices are busy, voices already in
Releasingare stolen before sounding ones (see the allocation modes for how a sounding victim is then chosen). 1/sqrt(N)level compensation: the mix is scaled by an equal-power factor that is smoothed, so stacking or releasing voices never steps the master level.
Per-Voice Signals
Each voice receives its own:
- V/Oct pitch — from the played note
- Gate — high while key held
- Trigger — pulse at note start
- Velocity — key strike strength
flowchart LR
VA[Voice Allocator]
VA -->|voct| VCO[VCO]
VA -->|gate| ENV[ADSR]
VA -->|velocity| VCA[Velocity VCA]
Unison and Detune
For thicker sounds, stack multiple detuned voices with UnisonConfig:
// 3 voices per note, spread 12 cents apart.
let config = UnisonConfig::new(3, 12.0);
poly.set_unison(config);
The slight detuning creates a chorus-like richness. detune_offset(i) and
pan_position(i) give the per-voice pitch offset and stereo pan.
MIDI Note to V/Oct
Quiver uses the standard conversion:
$$V_{oct} = \frac{\text{MIDI} - 60}{12}$$
| MIDI Note | Name | V/Oct |
|---|---|---|
| 48 | C3 | -1.0V |
| 60 | C4 | 0.0V |
| 72 | C5 | +1.0V |
| 84 | C6 | +2.0V |
Helper function:
fn midi_note_to_voct(note: u8) -> f64 {
(note as f64 - 60.0) / 12.0
}
Voice Stealing in Action
sequenceDiagram
participant K as Keyboard
participant VA as Allocator
participant V1 as Voice 1
participant V2 as Voice 2
K->>VA: C4 Note On
VA->>V1: Assign C4
Note over V1: Playing C4
K->>VA: E4 Note On
VA->>V2: Assign E4
Note over V1,V2: Playing C4 + E4
K->>VA: G4 Note On (voices full)
VA->>V1: Steal, assign G4
Note over V1: Now playing G4
Note over V2: Still playing E4
Legato Mode
For lead sounds, you might want legato: new notes don’t retrigger the envelope if a previous note is held.
sequenceDiagram
participant K as Keys
participant E as Envelope
K->>E: C4 on
Note over E: Attack→Sustain
K->>E: D4 on (C4 still held)
Note over E: Pitch slides, no retrigger
K->>E: C4 off, D4 still held
Note over E: Sustain continues
K->>E: D4 off
Note over E: Release
Performance Considerations
Polyphony multiplies CPU usage:
- 8 voices × 4 oscillators = 32 oscillators
- Each voice has its own filter, envelope, etc.
Quiver’s block processing helps:
// Process multiple samples at once
let mut block = AudioBlock::new();
for voice in voices.iter_mut() {
voice.process_block(&mut block);
}
That concludes the Tutorials section. Next, explore How-To Guides for task-focused recipes.