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

Building a Sequenced Bass

Let’s create something musical: a step sequencer driving a bass synthesizer. This is the foundation of countless electronic music tracks.

flowchart LR
    CLK[Clock] -->|tempo| SEQ[Step<br/>Sequencer]
    SEQ -->|V/Oct| VCO[VCO]
    SEQ -->|gate| ENV[ADSR]
    VCO --> VCF[VCF]
    ENV --> VCF
    ENV --> VCA[VCA]
    VCF --> VCA
    VCA --> OUT[Output]

    style SEQ fill:#e74c3c,color:#fff
    style CLK fill:#50c878,color:#fff

The Step Sequencer

A step sequencer cycles through a series of values, advancing on each clock pulse:

Step:    1    2    3    4    5    6    7    8
CV:     ┌─┐  ┌─┐       ┌─┐  ┌─┐       ┌─┐  ┌─┐
        │ │  │ │       │ │  │ │       │ │  │ │
Gate:   └─┘  └─┘       └─┘  └─┘       └─┘  └─┘
        C3   D3  rest  G3   C3  rest  E3   D3

Each step can have:

  • CV value: The pitch (in V/Oct)
  • Gate: On or off (rest = off)

V/Oct and Musical Pitches

Converting notes to voltages:

NoteMIDIV/Oct
C348-1.0V
C4600.0V
D462+0.167V
E464+0.333V
G467+0.583V
C572+1.0V

The formula:

$$V = \frac{\text{MIDI} - 60}{12}$$

Building the Patch

//! Tutorial: Building a Sequenced Bass
//!
//! A step sequencer driving a classic subtractive bass voice.
//! This pattern is the foundation of house, techno, and many other genres.
//!
//! # Why a step sequencer instead of manual gate/pitch control
//!
//! Earlier tutorials (`tutorial_envelope.rs`, `first_patch.rs`) drove pitch
//! and gate signals from Rust code directly. A `StepSequencer` moves that
//! job *into the graph*: it holds up to 8 (V/Oct pitch, gate-on/off) pairs
//! and advances one step every time its `clock` input receives a rising
//! edge, outputting that step's stored CV and gate. This is exactly how a
//! hardware step sequencer module works — the tempo comes from a separate
//! `Clock` module, decoupled from the pattern itself, so the same 8-step
//! bassline can run at any tempo just by changing the clock's rate.
//!
//! Two things worth understanding about the signal chain below:
//! - **V/Oct pitch**: each step stores a control voltage where every extra
//!   1V is one octave up (`(midi_note - 60) / 12` converts a MIDI note to
//!   this scale, since 12 semitones = 1 octave = 1V).
//! - **Gate-gated triggering**: the sequencer only asserts its gate output
//!   while the clock pulse itself is high *and* that step is marked "on" —
//!   a "rest" step in the pattern lets the ADSR's release tail finish
//!   naturally instead of re-triggering, which is what keeps a rest sounding
//!   like silence rather than a stuck note.
//!
//! Run with: cargo run --example tutorial_sequenced_bass

use quiver::prelude::*;

/// 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);

    // Our bassline: C3, D3, rest, G2, C3, rest, E3, D3
    let pattern = [
        (48, true), // C3
        (50, true), // D3
        (0, false), // rest
        (43, true), // G2
        (48, true), // C3
        (0, false), // rest
        (52, true), // E3
        (50, true), // D3
    ];

    // Step sequencer - stores our bassline pattern. Steps must be programmed
    // with `set_step(index, voct, gate)` *before* the module is handed to
    // `patch.add`: once a module is inside the graph it's only reachable by
    // port name, not by its concrete Rust type, so this is the only chance
    // to configure it directly.
    let mut seq_module = StepSequencer::new();
    for (i, (note, active)) in pattern.iter().enumerate() {
        seq_module.set_step(i, midi_to_voct(*note), *active);
    }
    let seq = patch.add("seq", seq_module);

    // Master clock - sets the tempo. `out` is the main pulse (2 Hz / 120 BPM
    // by default); `div2`/`div4` divide it further for slower sub-patterns.
    // We use the un-divided `out` so the 8-step pattern advances once per
    // pulse.
    let clock = patch.add("clock", Clock::new(sample_rate));

    // Bass voice: VCO → VCF → VCA
    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));

    // Output
    let output = patch.add("output", StereoOutput::new());

    // Clock → Sequencer
    patch.connect(clock.out("out"), seq.in_("clock")).unwrap();

    // Sequencer → Voice
    patch.connect(seq.out("cv"), vco.in_("voct")).unwrap();
    patch.connect(seq.out("gate"), env.in_("gate")).unwrap();

    // Audio path
    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();

    // Envelope → Filter & VCA
    patch.connect(env.out("env"), vcf.in_("cutoff")).unwrap();
    patch.connect(env.out("env"), vca.in_("cv")).unwrap();

    patch.set_output(output.id());
    patch.compile().unwrap();

    println!("=== Sequenced Bass Demo ===\n");

    fn note_name(note: u8) -> &'static str {
        match note % 12 {
            0 => "C",
            1 => "C#",
            2 => "D",
            3 => "D#",
            4 => "E",
            5 => "F",
            6 => "F#",
            7 => "G",
            8 => "G#",
            9 => "A",
            10 => "A#",
            11 => "B",
            _ => "?",
        }
    }

    println!("Bassline pattern (now actually programmed into the sequencer):");
    for (i, (note, active)) in pattern.iter().enumerate() {
        if *active {
            let voct = midi_to_voct(*note);
            let octave = (note / 12) - 1;
            println!(
                "  Step {}: {}{} ({:.3}V)",
                i + 1,
                note_name(*note),
                octave,
                voct
            );
        } else {
            println!("  Step {}: rest", i + 1);
        }
    }

    // The clock's main "out" pulses at 2 Hz by default (120 BPM), so each of
    // the 8 steps lasts half a second — one full pass through the pattern
    // takes 4 seconds.
    //
    // One quirk worth knowing: `Clock`'s phase starts at 0, which is already
    // inside its pulse window, so the very first sample tick delivers a
    // rising edge before we've heard anything — the sequencer advances past
    // step 0 in zero time. Since the pattern loops forever in a real patch,
    // this just means playback effectively starts one step ahead; we offset
    // our step index by one below so the printed labels match what's
    // actually sounding.
    let step_samples = (sample_rate * 0.5) as usize;
    println!("\nRunning one pass through the pattern (4.0s)...\n");

    for i in 0..pattern.len() {
        let (note, active) = pattern[(i + 1) % pattern.len()];
        let mut peak = 0.0_f64;
        for _ in 0..step_samples {
            let (left, _) = patch.tick();
            peak = peak.max(left.abs());
        }

        let label = if active {
            format!("{}{}", note_name(note), (note / 12) as i32 - 1)
        } else {
            "rest".to_string()
        };
        let bar = "█".repeat((peak * 2.0) as usize);
        println!(
            "Step {} ({:>4}): {:5.2}V |{}",
            (i + 1) % pattern.len() + 1,
            label,
            peak,
            bar
        );
    }

    println!("\nThe sequencer cycles through the pattern,");
    println!("triggering the envelope on each gated step and resting on the others.");
}

Clock Divisions

The clock module provides multiple time divisions:

graph TB
    MASTER[Master Clock<br/>120 BPM] --> D1[1/1<br/>Whole notes]
    MASTER --> D2[1/2<br/>Half notes]
    MASTER --> D4[1/4<br/>Quarter notes]
    MASTER --> D8[1/8<br/>Eighth notes]
    MASTER --> D16[1/16<br/>Sixteenth notes]

For a bassline at 120 BPM:

  • 1/8 notes = 4 Hz (classic house tempo)
  • 1/16 notes = 8 Hz (driving techno)

Filter Envelope Relationship

The key to punchy bass is the filter envelope:

Attack:  Fast (5ms)
Decay:   Medium (100-200ms)
Sustain: Low (20-40%)
Release: Quick (50-100ms)

This creates the characteristic “pluck” where brightness fades quickly.

Accent and Dynamics

Real sequences have accents—emphasized notes. Implement with velocity:

sequenceDiagram
    participant SEQ as Sequencer
    participant ENV as Envelope

    SEQ->>ENV: Step 1 (normal)
    Note over ENV: Attack → Sustain

    SEQ->>ENV: Step 2 (accented)
    Note over ENV: Attack → Higher peak<br/>→ Sustain

Classic Patterns

House Bass

Step: 1  2  3  4  5  6  7  8
Note: C  -  C  -  C  -  C  C

The off-beat creates the groove.

Acid (TB-303 Style)

Step: 1  2  3  4  5  6  7  8
Note: C  C  D  -  F  -  D  C
Acc:  X           X
Slide:   →     →

Accents and slides define the style.

Minimal Techno

Step: 1  2  3  4  5  6  7  8
Note: C  -  -  -  C  -  -  -

Space and repetition create hypnotic effect.

Going Further

  • Add slide/portamento with SlewLimiter
  • Randomize steps with BernoulliGate
  • Quantize to scale with Quantizer
  • Layer with detuned second VCO

Next: FM Synthesis Basics