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

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”?

StrategyDescriptionBest For
RoundRobinSteal oldest voiceEven wear
QuietestStealSteal softest voiceMinimal artifacts
OldestStealSteal note held longestPredictable
NoStealIgnore new notesPad sounds
HighestPriorityHigh notes steal lowMelodies
LowestPriorityLow notes steal highBass 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 &note 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 &note 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 Free only 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 Releasing are 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 NoteNameV/Oct
48C3-1.0V
60C40.0V
72C5+1.0V
84C6+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.