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.

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 (CB, 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 .json file
  • 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 renderToBuffer with per-pitch params.notes. The Juno engine works standalone but may need its render() 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