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:
| Note | MIDI | V/Oct |
|---|---|---|
| C3 | 48 | -1.0V |
| C4 | 60 | 0.0V |
| D4 | 62 | +0.167V |
| E4 | 64 | +0.333V |
| G4 | 67 | +0.583V |
| C5 | 72 | +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