Step Sequencer
A multi-track step sequencer with a strip-based architecture. Each strip groups one or more sounds with a shared effects chain (saturation, glue compressor, DJ filter, volume) and pattern-based step sequencing.
Available at /synth/sequencer.
Quick Links
| Document | Description |
|---|---|
| overview | Architecture overview, plugin pattern, module types |
| engine | initSynth orchestrator, signal chain wiring, renderToBuffer |
| distortion | Distortion pipeline, curve generation, chain builder |
| preset-manager | Preset CRUD, localStorage persistence |
| template-manager | Template system for constrained randomisation |
| playback | AudioContext lifecycle, preview, VU meter, download |
Architecture
init-sequencer.js
→ restores saved state from localStorage (or starts fresh)
→ creates SequencerEngine
→ calls buildSequencerUI(container, seq) (DOM builder)
SequencerEngine
├── addStrip(name) — creates empty strip with default pattern
├── addSoundToStrip(id, eng) — creates track inside a strip
├── switchPattern(id, patId) — switches pattern (immediate if stopped,
│ deferred to bar boundary if playing)
├── addPattern/removePattern/renamePattern
├── setStep / setVelocity / setChance — per-step editing
├── start() — resumes AudioContext, begins rAF scheduler
├── stop() — cancels scheduler
└── _scheduleLoop() — lookahead scheduler, routes through
strip audio chain
Audio playback reuses the shared AudioContext from playback.js.
Sound rendering reuses renderToBuffer() from engine.js.
Strips
A strip is a container that groups sounds with a shared audio processing chain and pattern system. Each strip renders as a card containing:
┌─ Strip Card ──────────────────────────────────────────────────┐
│ [name] [M] [S] [✕] │
│ │
│ Comp: [On/Off] Thresh [===○==] -12 Ratio [===○==] 4 ... │
│ (always visible) │
│ │
│ [Pattern 1] [Pattern 2] [Pattern 3] [+] │
│ │
│ ┌─ sound card ───┐ ┌─ sound card ───┐ ┌─ Sat ─┐ ┌─ Comp ─┐│
│ │ ... │ │ ... │ │ [fdr] │ │ [fdr] ││
│ └────────────────┘ └────────────────┘ └───────┘ └───────┘│
│ ┌─ Filt ─┐ ┌─ Vol ─┐│
│ │ [fdr] │ │ [fdr] ││
│ └───────┘ └───────┘│
│ │
│ [+ Add Sound] │
└─────────────────────────────────────────────────────────────────┘
Effect cards (Saturation, Compressor, DJ Filter, Volume) are hidden when the strip has no sounds and appear automatically when the first sound is added.
Strip Audio Chain
source → noteGain (per-trigger envelope)
→ volGain (strip volume)
→ duckGain (sidechain ducking, gain=1 normally)
→ Saturator (tanh waveshaper, drive 0–1)
→ Compressor (6ms attack, 12dB soft knee, auto makeup gain)
→ DJ Filter (blend 0→1 maps LPF→Flat→HPF, springs to neutral at bar boundary)
→ strip EQ (3-band: lowshelf 250Hz, peaking 1kHz, highshelf 8kHz)
→ MasterGain (0.5 headroom) → Master Limiter (-6dB threshold, 20:1)
→ destination
The duckGain node is inserted between volGain and the saturator. Its gain is
normally 1. When a sidechain source fires, _playNote schedules a gain dip:
gain → 1 − (velocity × amount^0.5) over attack ms, then recovers over
release ms. See Sidechain Ducking below.
Patterns
Each strip has one or more patterns. A pattern stores a snapshot of all sound cards' step states within that strip. Switching patterns saves the current step data to the old pattern and loads the new pattern's snapshot.
- When stopped: pattern switch executes immediately
- During playback: pattern switch queues to the next bar boundary (all sounds in the strip reach step 0 simultaneously)
- The clicked pattern pill shows a pulsing animation while waiting
- "+" button adds a new pattern (clones the current pattern's state)
- Double-click a pattern pill to rename via dialog
- Pattern delete button (✕) shown when more than one pattern exists
SequencerEngine
State
| Property | Type | Description |
|---|---|---|
sounds |
Array | List of sound objects (shared across all strips) |
strips |
Array | List of strip objects |
bpm |
number | Tempo 40–300 (default 120) |
scaleRoot |
string | Global key root (C–B, default C) |
scaleName |
string | Scale type (chromatic, major, minor, pentatonic, blues) |
defaultOctave |
number | Base octave for Note tab (1–9, default 3) |
totalSteps |
number | Fixed at 128 (steps stored per sound) |
isPlaying |
boolean | Transport state |
Each strip:
| Property | Type | Description |
|---|---|---|
id |
string | Unique identifier |
name |
string | Editable strip name |
soundIds |
array | Ordered list of sound IDs in this strip |
volume |
number | 0–1 master volume (default 0.75) |
mute |
boolean | Mute toggle |
solo |
boolean | Solo toggle (overrides mute, sums soloed strips) |
compressor |
object | { amount: 0–1 } single-fader glue compressor |
saturation |
object | { amount: 0–1 } tanh waveshaper drive |
filter |
object | { blend: 0.5 } DJ filter (0=LPF, 0.5=Flat, 1=HPF) |
patterns |
array | Pattern objects with snapshots |
activePattern |
string | Current pattern ID |
Each sound/track:
| Property | Type | Description |
|---|---|---|
id |
string | Unique identifier |
name |
string | Editable track name |
engineId |
string | Engine type (kick, snare, hihat, bia-clone, juno, neuro) |
engine |
object | Reference to the SynthEngine |
presetName |
string | Loaded preset name (null = defaults) |
activeSteps |
number | How many steps this track plays (16–128) |
params |
object | Current synth parameters |
stages |
array | Current distortion stages |
steps |
array | 128 elements of {active, velocity, chance, mod, note} |
sidechain |
object | Ducking config: {sourceId, amount, attack, release} |
modRoute |
array | Modulation routing: [{param, atten, bipolar}] |
Per-step Properties
| Property | Range | Default | Description |
|---|---|---|---|
active |
bool | false | Pad on/off (no default triggers) |
velocity |
0–1 | 0.85 | Volume multiplier |
chance |
0–100 | 100 | Probability (%) that the step fires |
mod |
0–1 | 0 | Modulation amount (routed via modRoute) |
note |
-1–71 | -1 | Scale note index (-1 = off, 0+ = note in current scale across 3 octaves) |
Strip Methods
| Method | Description |
|---|---|
addStrip(name) |
Creates a new empty strip with "Pattern 1" |
removeStrip(stripId) |
Removes strip and all its sounds |
addSoundToStrip(stripId, engineId) |
Creates a new sound in the strip |
removeSoundFromStrip(stripId, soundId) |
Removes a sound from the strip |
addPattern(stripId, name) |
Adds a new pattern (clones current state) |
removePattern(stripId, patternId) |
Removes pattern (minimum 1 kept) |
renamePattern(stripId, patternId, name) |
Renames a pattern |
switchPattern(stripId, patternId) |
Switches pattern (immediate/bar boundary) |
Sound Methods
| Method | Description |
|---|---|
loadPreset(soundId, name) |
Loads a preset snapshot, re-renders |
applyTemplate(soundId, tmpl) |
Applies template, re-renders |
randomiseSound(soundId) |
Randomises unlocked params, re-renders |
randomiseVelocities(soundId) |
Sets random velocity (0.3–1.0) on active steps |
randomiseChance(soundId) |
Sets random chance (0–100%) on active steps |
setStep(soundId, idx, active) |
Toggles a step, extends/contracts activeSteps |
setVelocity(soundId, idx, v) |
Sets velocity 0–1 |
setChance(soundId, idx, chance) |
Sets chance percentage 0–100 |
setBpm(n) |
Sets tempo 40–300 |
reRenderSound(soundId) |
Re-renders a single track |
reRenderAll() |
Re-renders all tracks |
auditionSound(soundId) |
Plays the track once immediately |
start() / stop() |
Transport control |
Persistence
| Method | Description |
|---|---|
_save() |
Debounced (150ms) localStorage write |
restore(data) |
Restores full state including strips + patterns |
saveSnapshot(name) |
Serializes full state and saves as named snapshot to localStorage('sequencer_snapshots') |
loadSnapshot(name) |
Persists snapshot to auto-save key then reloads page |
deleteSnapshot(name) |
Removes a named snapshot |
listSnapshots() |
Returns [{name, date, data}] |
exportSnapshot(name) |
Returns plain object for JSON download |
importSnapshot(data) |
Saves as snapshot + persists to auto-save + reloads |
Scheduler
The scheduler uses a lookahead approach with requestAnimationFrame:
_scheduleLoop() {
stepDuration = 60 / bpm / 4
lookahead = 0.1 seconds
while (nextEventTime < now + lookahead):
for each sound:
step = sound.steps[sound._currentStep]
if step.active AND chance check:
// Build live chain and trigger
chain = sound.engine.buildLiveChain(ctx, params, stages)
chain.masterGain → strip.volGain → duckGain → saturator → ...
p = applyMod(sound, stepIdx, params)
// Override frequency if step has a note
if step.note >= 0:
p.freq = midiToFreq(scaleNote + octave × 12)
sound.engine.trigger(ctx, chain, p, time, step.velocity)
// Sidechain ducking
for each target with sidechain.sourceId === sound.id:
schedule gain dip on target's duckGain
sound._currentStep = (sound._currentStep + 1) % sound.activeSteps
nextEventTime += stepDuration
// Execute pending pattern/filter switches at bar boundary
_executePendingPatterns()
requestAnimationFrame(_scheduleLoop)
}
Each track loops independently at its own activeSteps length. A kick with
16 steps loops every 16 steps while a hi-hat with 32 steps runs through all
32 before looping.
Effect Cards
All four effect cards share the same vertical fader design (48px wide, flex
height, writing-mode:vertical-lr orientation).
Saturation (Sat)
Uses a tanh-based waveshaper. At 0% the curve is identity (no saturation).
At 100% the curve applies heavy tape-style saturation (tanh(x·11)/tanh(11)),
clipping peaks musically while preserving the waveform envelope.
| Fader | Range | Display |
|---|---|---|
| Drive | 0–1 | "Off" / percentage |
Glue Compressor (Comp)
Single-fader compressor with auto makeup gain:
| Fader | Range | Maps To |
|---|---|---|
| Amount | 0–1 | Threshold 0 → -30 dB, Ratio 1:1 → 10:1 |
Fixed parameters: 6ms attack, 60ms release, 12dB soft knee. Makeup gain increases proportionally (+0 to +6 dB) to compensate for gain reduction, keeping perceived volume consistent.
DJ Filter (Filt)
Spring-loaded filter that returns to neutral at the next bar boundary on release. Uses a single vertical fader:
| Position | Filter Type | Frequency |
|---|---|---|
| 0.0 | Lowpass | 20 Hz |
| 0.5 | Flat (neutral) | 20000 Hz |
| 1.0 | Highpass | 20 Hz |
Q is fixed at 0.7 for a smooth, musical response.
Volume (Vol)
Strip master volume fader. Range 0–100%, controls the per-strip output level before the master bus.
Master Bus
All strips sum into a shared master bus with: - Master gain: 0.5 (6dB headroom for multi-strip summing) - Brickwall limiter: -6 dB threshold, 20:1 ratio, 1ms attack
Step Grid
Each track has up to 8 pages of 16 steps (128 total):
[1] [2] [3] [4] [5] [6] [7] [8] ← page pills (blue = current,
┌──┬──┬──┬──┐ yellow = has triggers,
│1 │2 │3 │4 │ dimmed = beyond activeSteps)
├──┼──┼──┼──┤
│5 │6 │7 │8 │ 4×4 grid, step numbers 1–16
├──┼──┼──┼──┤ click to toggle pad on/off
│9 │10│11│12│ right-click for velocity prompt
├──┼──┼──┼──┤
│13│14│15│16│
└──┴──┴──┴──┘
- Clicking a dimmed page pill extends the track's step count to include it
- Active pads show warm amber glow (3px border, theme primary colour)
- Currently-playing pad has a red border highlight
- Opacity reflects velocity (faint = low velocity, bright = full)
Step Count
Each track's activeSteps grows when a step on a new page is activated:
- Page 2 (steps 16–31) → activeSteps = 32
- ...
- Page 8 (steps 112–127) → activeSteps = 128
Removing all steps from a page contracts the count back down.
Tab Controls
| Tab | Description |
|---|---|
| Trig | 4×4 step trigger grid with page pills (8 pages) |
| Vel | 4×4 velocity grid — click-drag up/down to adjust 0–1 |
| Chance | 4×4 probability grid — set each step's trigger chance 0–100% |
| Random | Randomise Steps (params + re-render) and Randomise Velocities |
| Duck | Sidechain ducking — coming soon |
| Choke | Choke groups — coming soon |
Page pill state is shared across all tabs — switching tabs preserves the current page, and page pills show active/trigger status in all tabs.
Transport Controls
| Control | Shortcut | Description |
|---|---|---|
| ▶ Play / ■ Stop | Space | Toggles playback |
| BPM | — | Tempo 40–300 |
| Key (C–B) | — | Global scale root for Note tab |
| Scale (chromatic/major/minor/pentatonic/blues) | — | Scale note set for Note tab |
| Oct (1–9) | — | Base octave for Note tab (default 3) |
| Save | — | Save current state as a named project snapshot |
| Snapshots | — | Open snapshot manager (load/export/import/delete) |
| + Add Strip | — | Creates a new empty strip |
Project Snapshots
The sequencer supports named snapshots of the entire state (all strips, sounds,
patterns, BPM, master settings, EQ, sidechain, sends). Saved to
localStorage('sequencer_snapshots').
- Save button in the transport bar prompts for a name and creates a snapshot
- Snapshots button opens a modal listing all saved snapshots with Load /
Export / Del buttons per entry, plus Import for uploading a
.jsonfile - Loading a snapshot persists it to
localStorage('sequencer_state')and reloads the page to rebuild the UI from the restored data - Export downloads a JSON file, Import reads a JSON file and saves as a new snapshot (also immediately loads it)
The _serialize() method captures everything — BPM, scale, master volume,
glue compressor params, reverb params, all strips with their pattern snapshots,
and all sounds with their full param state, steps, sidechain config, mod routes,
and sends.
Sound Card Controls
| Control | Description |
|---|---|
| Name | Click to edit via dialog |
Engine (e.g. Kick Synth) |
Click to change via dropdown dialog (preserves steps) |
Preset (Default / saved) |
Click to load via dropdown dialog |
| 🎧 | Audition — plays the track once immediately |
| ⚙ | Open in synth page (same-tab, with preset pre-loaded) |
| ✕ | Remove sound from strip |
Sound Card Icon Bar
Each sound card has an icon bar with randomisation and undo buttons:
| Button | Action |
|---|---|
| RS (Randomise Sound) | Randomises unlocked param values, re-renders current tab |
| RC (Randomise Chance) | Sets random chance (70–100%) on active steps, re-renders Chance tab |
| RT (Randomise Triggers) | Randomises which steps are active (≈40% density), re-renders Trig tab |
| RV (Randomise Velocities) | Sets random velocity (0.3–1.0) on active steps, re-renders Vel tab |
| RM (Randomise Mod) | Sets random mod (–1.0–1.0) on active steps, re-renders Mod tab |
| Undo | Restores previous state (params + steps) from undo snapshot |
Each randomisation button also re-renders the relevant tab directly (not just the current tab), so the user sees the randomised values immediately.
Step Object Integrity
Every step mutation uses _replaceStep(sound, idx, patch) which creates a
new object at the array index rather than mutating properties in-place.
This guarantees no two array positions can share the same object reference
(which would cause unrelated steps to change together). The randomisation
functions also create new step objects per-index instead of using forEach
with in-place mutation.
When loading saved data, restore and _loadPattern pad the steps array
to totalSteps (128) with fresh objects to prevent short arrays from
old-format saves.
Sound Card Tabs
Each sound card has a tab bar with context-sensitive tabs. Drum sounds show Trig, Vel, Chance, Note, Duck, EQ, Mod. Turing Machine sounds show Gates, CV, Seed, Note, Duck, EQ, Mod.
Note Tab
Displays a 4×4 grid of step pads where each pad shows the note name
(e.g. C3, E♯4) or — for inactive steps. The note is determined by the
global Key and Scale transport controls and the Oct setting:
- Click an empty pad to set the first scale degree and activate the step.
- Click again to cycle through the scale's notes across 3 octaves.
- Right-click to clear the note (step becomes inactive).
- The octave range uses the transport Oct setting as the base.
- Note data is stored per-step as
note(index into the scale grid).
When a step has a note ≥ 0, _playNote overrides the sound's frequency:
freq = midiToFreq(semitone + (defaultOctave + octaveOffset) × 12).
Duck Tab (Sidechain Compression)
Configures ducking of this sound's volume triggered by another sound:
| Control | Range | Default | Description |
|---|---|---|---|
| Source | dropdown | None | Sound that triggers the duck |
| Amount | 0–100% | 50% | Max gain reduction (power curve applied) |
| Attack | 1–50 ms | 5 ms | Time to reach full duck |
| Release | 10–500 ms | 100 ms | Time to recover to full volume |
A real-time envelope canvas at the bottom of the tab shows the inverted duck
curve (gain reduction over time) using the same power-curve calculation as
_playNote.
When the source sound fires, _playNote schedules on the target strip's
duckGain node:
g = targetStrip.duckGain.gain
dip = max(0, 1 − velocity × amount^0.5)
g.linearRampToValueAtTime(dip, time + attack)
g.linearRampToValueAtTime(1, time + attack + release)
EQ Tab
Three-band EQ with vertical faders and a volume fader:
| Control | Label | Range | Description |
|---|---|---|---|
| L (250 Hz) | Lowshelf | −12–+12 dB | Low-end boost/cut |
| M (1 kHz) | Peaking | −12–+12 dB | Mid-range boost/cut |
| H (8 kHz) | Highshelf | −12–+12 dB | High-end boost/cut |
| Vol | Volume | 0–100% | Per-sound volume (sequencer-side only, not per-preset) |
EQ parameters are engine params (eqLow, eqMid, eqHigh) and are updated
in real-time on the sound's _liveChain.
Mod Tab
Modulation routing table. Each row assigns a step's mod value (0–1) to a
target synth parameter:
| Column | Description |
|---|---|
| Param | Dropdown of all engine params |
| Atten | 0–100% modulation depth |
| ± | Toggle bipolar (negative values allowed) |
| ✕ | Remove this route |
When a step with mod > 0 fires, _applyModLive clones the sound's params
and applies p[param] += mod × atten × (max − min) / 2 for each active route.
Turing Machine Sequencer (WIP)
A shift-register style sequencer based on the Music Thing Modular Turing Machine Eurorack module. Currently implemented but requires a working melodic synth engine (e.g. Juno) in the sequencer to produce pitched output. Drum engines (kick, snare, hi-hat) will trigger but at a fixed pitch.
Status
- [x] Data model (
sequencerType,turingConfig,register,_pitchCache) - [x] Scale-constrained CV generation (8 scales: chromatic, major, minor, pentatonic, blues, dorian, mixolydian)
- [x] Settings modal (gear button) with Drum Grid / Turing Machine toggle
- [x] Dynamic tabs: Drum = Trig/Vel/Chance/Random, Turing = Gates/CV/Seed
- [x] Gates tab — horizontal stage cells, click to toggle, current-step highlight
- [x] CV tab — drag up/down on cells to adjust pitch (scale-snapped)
- [x] Seed tab — stability slider, randomise/lock buttons, stages/length/root/range
- [x] Register advance on each step (stability-based feedback)
- [x] Per-pitch rendering and caching (
_pitchCache) - [x] Real-time gate visual updates during playback via
_onStepChange - [ ] Sound output — blocked: no melodic synth engine renders pitch in the sequencer context. Needs investigation.
- [ ] Piano roll view for manual step editing
- [ ] Register freeze / lock visual feedback
- [ ] CV quantisation visualisation (scale snap indicators)
How it works
Register: N stages (default 8, range 1–16). Each stage has a gate (0/1)
and a cv (MIDI note 0–127, snapped to the selected scale).
Timing: The step grid's active property is ignored. Playback is
controlled entirely by the register gates — a step fires if
register[stageIdx].gate === 1 AND the step's chance check passes.
The activeSteps property determines the loop length (defaults to
max(16, length × 2) so each stage gets 2 timing steps).
Advance: On each step, the register rotates right: the last stage falls
off and a new value enters at stage 1. The new value is either a copy of the
falling-off stage (locked loop) or a random gate + CV — controlled by the
stability slider.
CV generation: Random CVs are generated within rootNote ± range and
snapped to the nearest scale degree of the selected scale.
Pitch rendering: When switching to Turing mode, rerenderTuringRegister
renders a one-shot buffer at each unique CV pitch via renderToBuffer, stored
in _pitchCache[noteNumber]. On playback, _playNote looks up the current
stage's CV in the cache.
Known issues
- No sound output from Turing mode with current engines — needs a melodic
synth (Juno) that renders correctly via
renderToBufferwith per-pitchparams.notes. The Juno engine works standalone but may need itsrender()function verified for offline context with per-pitch params. - CV drag during playback skips pitch re-rendering (uses
skipRender: true) to avoid flooding the render queue — cached pitch may be stale until the next full register render. - Gate cells update visually during playback but CV cells do not — user must revisit the CV tab to see current CV values.
- Advanced features: freeze/lock indicator, CV scale snap visualisation, piano roll editing, pattern-synced register seeding.
Tests
node --test tests/js/synth/**/*.test.js
15 dedicated sequencer tests cover: - Constructor defaults (bpm, sounds, strips) - addSoundToStrip creates valid tracks with correct step patterns - Engine switching (returns null for unknown engines) - Track removal from strip - BPM clamping - 128-step storage per track - Per-track activeSteps (independent expansion and contraction) - setStep / setVelocity / setChance round-trip - randomiseSound modifies params - start/stop transport and per-track step reset - auditionSound plays without error - Default step pattern (all steps off)
Files
| File | Role |
|---|---|
templates/synth_sequencer.html |
Sequencer page layout and CSS |
static/js/synth/sequencer.js |
SequencerEngine class (strips, patterns, scheduler, audio chain, persistence) |
static/js/synth/sequencer-ui.js |
DOM builder (transport, strip cards, effect cards, sound cards, tabs, step grids) |
static/js/synth/init-sequencer.js |
Bootstrap — restore state, build UI |
tests/js/synth/sequencer.test.js |
Sequencer engine tests |
src/routers/synth.py |
GET /synth/sequencer |