← JamDojo Strudel Programming

Strudel Programming

This guide teaches you how to think like a programmer in Strudel. The key insight: everything can be stored in variables and reused. Notes, rhythms, envelopes, effects, even modulation—all can be assigned, combined, and applied across your composition.

Prerequisites: Basic familiarity with code (what a function is, what = does). No Strudel experience required.


Part 1: The Power of Variables

Everything Is Assignable

In Strudel, anything you can write can be stored in a variable:

// Store a note pattern
const melody = note("c4 e4 g4 b4")

// Store a sound
const synth = s("sawtooth")

// Store an effect chain
const fx = x => x.lpf(800).room(0.3)

// Combine them
melody.s("sawtooth").lpf(800).room(0.3)

But that last line is verbose. Watch what happens when we make things reusable:

const melody = note("c4 e4 g4 b4")
const withBass = x => x.s("sawtooth").lpf(600)

withBass(melody)

The function withBass can now be applied to any melody.

Why Variables Matter

Without variables, you repeat yourself constantly:

// Repetitive - same settings everywhere
note("c2 eb2 g2").s("sawtooth").lpf(600).attack(0.01).decay(0.15).sustain(0)
note("g2 bb2 d3").s("sawtooth").lpf(600).attack(0.01).decay(0.15).sustain(0)
note("f2 ab2 c3").s("sawtooth").lpf(600).attack(0.01).decay(0.15).sustain(0)

With variables, you define once and apply everywhere:

// Define the sound once
const acidBass = x => x
.s("sawtooth")
.lpf(600)
.attack(0.01)
.decay(0.15)
.sustain(0)

// Apply to any notes
cat(
acidBass(note("c2 eb2 g2")),
acidBass(note("g2 bb2 d3")),
acidBass(note("f2 ab2 c3"))
)

Change acidBass once, and every pattern using it updates.


Part 2: Storing Sound Designs

ADSR Envelopes as Variables

Envelopes shape how sounds evolve over time. Instead of typing .attack().decay().sustain().release() repeatedly, store envelope shapes:

// Define envelope shapes
const pluck = x => x.attack(0.001).decay(0.12).sustain(0).release(0.1)
const pad = x => x.attack(0.8).decay(0.3).sustain(0.7).release(1.5)
const stab = x => x.attack(0.02).decay(0.1).sustain(0.5).release(0.3)

// Apply to any sound - watch the sharp attack in the scope
pluck(note("c2 eb2 g2 bb2").s("sawtooth").lpf(1200))._scope()

Now try changing pluck to pad:

const pluck = x => x.attack(0.001).decay(0.12).sustain(0).release(0.1)
const pad = x => x.attack(0.8).decay(0.3).sustain(0.7).release(1.5)
const stab = x => x.attack(0.02).decay(0.1).sustain(0.5).release(0.3)

// Same notes, different character - watch the slow fade-in
pad(note("[c3,eb3,g3]").s("sawtooth").lpf(1500).room(0.5))._scope()

Genre connection: The pluck envelope is classic TB-303 acid bass. The pad is ambient/chillout. The stab is disco brass.

Complete Sound Presets

Combine envelope + effects into full presets:

// Full sound presets
const acidBass = x => x
.s("sawtooth")
.attack(0.001)
.decay(0.15)
.sustain(0)
.lpf(1200)
.lpq(10)

const warmPad = x => x
.s("sawtooth")
.attack(1)
.decay(0.5)
.sustain(0.7)
.release(2)
.lpf(1500)
.room(0.5)

const brightLead = x => x
.s("square")
.attack(0.01)
.decay(0.2)
.sustain(0.6)
.lpf(3000)
.delay(0.25)

// Use them
stack(
acidBass(note("c2 c2 eb2 g2")),
warmPad(note("[c4,eb4,g4]")).gain(0.4),
brightLead(note("c5 ~ eb5 ~")).gain(0.3)
)

Your sound palette is now a library of reusable presets.


Part 3: Signals and Modulation

Signals are continuous value generators—they produce numbers that change over time. This is the foundation of all modulation in electronic music: filter sweeps, tremolo, vibrato, auto-pan, and more.

Signal Types

Each signal has a distinct character. The ._scope() visualization shows you the waveform in real-time:

// Compare signal shapes on filter
// Try changing 'sine' to: saw, tri, square, perlin
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(300, 2000).slow(2))
.lpq(8)
._scope()
SignalShapeMusical Use
sineSmooth waveFilter sweeps, tremolo, vibrato
sawRamp up, resetBuildups, risers
triTriangleSmooth modulation (like sine but more linear)
squareOn/offGating, rhythmic chops
randRandom per eventHumanization, chaos
perlinSmooth randomOrganic drift, natural variation
brandBinary random (0 or 1)Coin-flip effects, random panning
brandBy(p)Binary with probabilitySparse triggers, probabilistic gating

Saw — Perfect for buildups:

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(saw.range(200, 4000).slow(8))
.lpq(6)
._scope()

The filter ramps up over 8 cycles, then resets. Watch the waveform get brighter as the filter opens. Classic EDM riser.

Square — Rhythmic gating:

note("[c3,eb3,g3]")
.s("sawtooth")
.gain(square.range(0, 0.8).fast(4))
._scope()

The sound cuts in and out rhythmically—trance gate effect. The scope shows the sharp on/off pattern.

Perlin — Organic drift:

note("c4 e4 g4 b4")
.s("piano")
.pan(perlin.range(0.3, 0.7).slow(2))
.gain(perlin.range(0.6, 1).slow(3))
._scope()

Smooth, natural-feeling variation—no two cycles sound exactly the same.

Boolean Signals

Boolean signals output binary values (0 or 1), useful for probabilistic effects and binary control.

brand — 50/50 random:

// Random panning left or right
s("hh*8").pan(brand)._scope()

Each hit randomly pans fully left (0) or fully right (1).

brandBy — custom probability:

// 20% chance of being 1 (right)
s("hh*8").pan(brandBy(0.2))._scope()
// Probability can be a pattern too
s("hh*8").pan(brandBy(sine.range(0, 1).slow(4)))._scope()

The probability sweeps from 0% to 100% over 4 cycles.

Use cases for boolean signals:

SignalBehaviorUse Case
brand50/50 random 0 or 1Random panning, coin-flip effects
brandBy(0.2)20% chance of 1Sparse random triggers
brandBy(0.8)80% chance of 1Mostly-on with random drops

Combining with other signals:

// Use boolean to gate another signal
const gated = brand.mul(sine.range(0.3, 1).fast(4))

s("hh*8").gain(gated)._scope()

The gain oscillates, but randomly cuts to zero—creating unpredictable rhythmic gating.

Manipulating Signals

Signals can be transformed with methods. Understanding these is key to expressive modulation.

Speed: .slow() and .fast()

Control how quickly the signal oscillates:

// Slow = long sweeps (ambient, builds)
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(300, 2000).slow(8))
._scope()
// Fast = wobbles, vibrato, tremolo
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(300, 2000).fast(4))
._scope()
SpeedCyclesEffect
.slow(16)One oscillation per 16 cyclesGlacial evolution
.slow(4)One per 4 cyclesSlow sweep
.slow(1)One per cycleStandard LFO
.fast(2)2 per cycleWobble
.fast(8)8 per cycleVibrato/tremolo
.fast(32)32 per cycleAudio rate (creates new timbres)

Range: .range(min, max)

Map the signal (normally 0–1) to useful values:

// Filter frequency range
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(200, 3000).slow(4))
// Pan range (center-weighted)
note("c4 e4 g4 b4")
.s("piano")
.pan(sine.range(0.3, 0.7).slow(2))
// Subtle detune for vibrato
note("c4")
.s("sawtooth")
.sustain(1)
.detune(sine.range(-12, 12).fast(6))

Math Operations: .add(), .mul(), .sub(), .div()

Combine and transform signals mathematically:

Adding signals (offset):

// Add a base value to a signal
const baseFilter = 800
const modulation = sine.range(0, 600).fast(2)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(modulation.add(baseFilter))
.lpq(8)

Multiplying signals (scaling):

// Multiply to scale the range
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.mul(2000).add(500).slow(4))

Combining multiple signals:

// Fast wobble + slow sweep
const slowSweep = sine.range(400, 1200).slow(8)
const fastWobble = sine.range(0, 400).fast(4)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(slowSweep.add(fastWobble))
.lpq(10)

The filter slowly sweeps while also wobbling rapidly—layered modulation.

Segment: .segment(n)

Sample a continuous signal at discrete points:

// Continuous sine (smooth)
note("c4*8").s("piano").gain(sine.range(0.3, 1))._scope()
// Segmented sine (stepped)
note("c4*8").s("piano").gain(sine.segment(8).range(0.3, 1))._scope()

The segmented version creates discrete steps instead of smooth curves—sample-and-hold effect. Watch the scope to see the difference.

Storing Signals as Variables

Store signals for reuse and consistency:

// Define modulation sources
const slowSweep = sine.range(400, 2000).slow(8)
const fastWobble = sine.range(0, 800).fast(4)
const drift = perlin.range(-0.3, 0.3).slow(4)

// Apply the same modulation to different sounds
stack(
note("c2 eb2 g2 bb2").s("sawtooth").lpf(slowSweep),
note("c4 eb4").s("sine").pan(drift.add(0.5))
)

Both sounds share the same modulation character.

Modulation Library Pattern

Create a reusable library of modulation behaviors:

// Modulation library
const lfo = {
// Speed variants
glacial: sine.slow(16),
slow: sine.slow(4),
medium: sine,
fast: sine.fast(4),
veryFast: sine.fast(8),

// Shape variants
ramp: saw.slow(8),
drift: perlin.slow(4),
gate: square.fast(4),

// Pre-configured effects
filterSweep: sine.range(400, 2000).slow(4),
wobble: sine.range(0, 1).fast(4),
vibrato: sine.range(-10, 10).fast(6),
tremolo: sine.range(0.4, 1).fast(8),
autoPan: sine.range(0.2, 0.8).slow(2)
}

// Use from library
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(lfo.filterSweep)
.lpq(8)

Now lfo.filterSweep means the same thing everywhere in your code.

Applying Signals to Different Parameters

Signals can modulate almost any parameter:

Filter cutoff (wah/sweep):

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(300, 2500).slow(2))
.lpq(10)

Gain (tremolo):

note("[c3,eb3,g3]")
.s("sawtooth")
.gain(sine.range(0.2, 0.8).fast(8))
._scope()

Pan (auto-pan):

note("c4 e4 g4 b4")
.s("piano")
.pan(sine.range(0, 1).slow(2))
._scope()

Detune (vibrato):

note("c4")
.s("sawtooth")
.sustain(1)
.detune(sine.range(-15, 15).fast(5))
._scope()

Speed (tempo modulation):

note("c4 e4 g4 b4")
.s("piano")
.fast(sine.range(0.5, 2).slow(4))

Room/delay (spatial movement):

note("c4 e4 g4 b4")
.s("piano")
.room(sine.range(0.1, 0.6).slow(4))
.delay(sine.range(0, 0.4).slow(8))

Filter Presets with Modulation

Store complete filter configurations:

// Filter presets with built-in modulation
const filter = {
// Static filters
dark: x => x.lpf(600).lpq(2),
bright: x => x.lpf(3000).lpq(4),

// Modulated filters
slowSweep: x => x.lpf(sine.range(300, 2000).slow(8)).lpq(6),
fastSweep: x => x.lpf(sine.range(300, 2000).slow(2)).lpq(6),
acid: x => x.lpf(sine.range(300, 3000).slow(2)).lpq(15),
wobble: x => x.lpf(sine.range(200, 1500).fast(4)).lpq(8),

// Buildups
riser: x => x.lpf(saw.range(200, 4000).slow(16)).lpq(4),

// Random/organic
drift: x => x.lpf(perlin.range(400, 1600).slow(4)).lpq(4)
}

// Apply to bass
filter.acid(note("c2 c2 eb2 g2").s("sawtooth"))

Genre connection: filter.acid is classic TB-303. filter.wobble is dubstep/brostep. filter.riser is EDM buildups.

Advanced: Multi-Signal Modulation

Layer multiple signals for complex, evolving textures:

// Slow evolution + fast detail
const macro = sine.range(400, 1200).slow(16)   // overall arc
const micro = sine.range(0, 300).fast(4)        // wobble detail
const chaos = perlin.range(-100, 100).slow(2)   // organic variation

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(macro.add(micro).add(chaos))
.lpq(8)
._scope()

The filter has three layers of movement:

  • Slow 16-cycle sweep (macro structure)
  • Fast 4x wobble (rhythmic detail)
  • Perlin drift (organic unpredictability)

Watch the scope—you can see the complex, layered modulation in the waveform.


Part 4: Storing Rhythms

Rhythm Patterns as Variables

Rhythms are patterns too—store them and apply with .struct():

// Rhythm library
const fourFloor = "x ~ x ~"
const offbeat = "~ x ~ x"
const syncopated = "x ~ x [~ x]"
const breakbeat = "x ~ [x ~] x ~ [~ x] x ~"

// Apply rhythm to any sound
note("c2 eb2 g2 bb2")
.struct(syncopated)
.s("sawtooth")
.lpf(800)

Now try breakbeat instead of syncopated:

const breakbeat = "x ~ [x ~] x ~ [~ x] x ~"

note("c2 eb2 g2 bb2")
.struct(breakbeat)
.s("sawtooth")
.lpf(800)

Drum Kits as Variables

Store your drum sounds, then apply different rhythms:

// Drum kit
const kit = {
kick: s("bd").bank("RolandTR909"),
snare: s("sd").bank("RolandTR909"),
hat: s("hh").bank("RolandTR909"),
clap: s("cp").bank("RolandTR909")
}

// Rhythm patterns
const house = {
kick: "x ~ x ~",
snare: "~ x ~ x",
hat: "x x x x x x x x"
}

// Combine
stack(
kit.kick.struct(house.kick),
kit.snare.struct(house.snare),
kit.hat.struct(house.hat).gain(0.5)
)

Switch from house to breakbeat by changing only the rhythm object:

const kit = {
kick: s("bd").bank("RolandTR909"),
snare: s("sd").bank("RolandTR909"),
hat: s("hh").bank("RolandTR909")
}

// Different genre = different rhythms
const breakbeat = {
kick: "x ~ [~ x] ~ x ~ [x ~] ~",
snare: "~ ~ x ~ ~ [~ x] ~ x",
hat: "[x x] x [x x] x [x x] x [x x] x"
}

stack(
kit.kick.struct(breakbeat.kick),
kit.snare.struct(breakbeat.snare),
kit.hat.struct(breakbeat.hat).gain(0.5)
)

Same kit, completely different genre.


Part 5: Building a Sound Library

The Complete Pattern

Here’s how professionals organize their code—a reusable library:

// === ENVELOPES ===
const env = {
pluck: x => x.attack(0.001).decay(0.12).sustain(0).release(0.1),
pad: x => x.attack(0.8).decay(0.3).sustain(0.7).release(1.5),
stab: x => x.attack(0.02).decay(0.1).sustain(0.5).release(0.3)
}

// === FILTERS ===
const filter = {
dark: x => x.lpf(600).lpq(2),
bright: x => x.lpf(3000).lpq(4),
sweep: x => x.lpf(sine.range(300, 2000).slow(4)).lpq(8)
}

// === FULL SOUNDS ===
const sound = {
acidBass: x => filter.sweep(env.pluck(x.s("sawtooth"))),
warmPad: x => filter.dark(env.pad(x.s("sawtooth"))).room(0.5),
lead: x => filter.bright(env.stab(x.s("square"))).delay(0.2)
}

// === USE THE LIBRARY ===
stack(
sound.acidBass(note("c2 c2 eb2 g2")),
sound.warmPad(note("[c4,eb4,g4]")).gain(0.3)
)

Composing Sound Functions

Notice how sound.acidBass is built from filter.sweep + env.pluck + s("sawtooth"). You can compose functions:

// Building blocks
const env = {
pluck: x => x.attack(0.001).decay(0.15).sustain(0)
}

const filter = {
acid: x => x.lpf(sine.range(300, 2500).slow(2)).lpq(12)
}

// Compose them: filter(envelope(sound))
const acidBass = x => filter.acid(env.pluck(x.s("sawtooth")))

// Or use a pipe helper
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x)

const acidBass2 = pipe(
x => x.s("sawtooth"),
env.pluck,
filter.acid
)

// Both work the same
acidBass(note("c2 eb2 g2 bb2"))

Part 6: Higher-Order Functions

Higher-order functions are functions that take other functions as arguments or return new functions. They’re the key to building flexible, reusable sound design tools in Strudel.

Functions That Return Functions (Factories)

Create parameterized effect builders:

// Filter sweep factory — returns a configured effect function
const sweep = (min, max, speed) =>
x => x.lpf(sine.range(min, max).slow(speed)).lpq(10)

// Create specific sweeps from the factory
const slowSweep = sweep(200, 2000, 8)
const fastSweep = sweep(400, 3000, 2)
const subtleSweep = sweep(600, 1200, 4)

// Use them
note("c2 eb2 g2 bb2").s("sawtooth")
.apply(slowSweep)

Now try replacing slowSweep with fastSweep:

const sweep = (min, max, speed) =>
x => x.lpf(sine.range(min, max).slow(speed)).lpq(10)

const fastSweep = sweep(400, 3000, 2)

note("c2 eb2 g2 bb2").s("sawtooth")
.apply(fastSweep)

More factory examples:

// Tremolo factory
const tremolo = (rate, depth) =>
x => x.gain(sine.range(1 - depth, 1).fast(rate))

// Vibrato factory
const vibrato = (rate, cents) =>
x => x.detune(sine.range(-cents, cents).fast(rate))

// Delay factory
const echo = (time, feedback, wet) =>
x => x.delay(wet).delaytime(time).delayfeedback(feedback)

// Combine factories
note("c4")
.s("sawtooth")
.sustain(2)
.apply(vibrato(5, 15))
.apply(tremolo(4, 0.3))
.apply(echo(0.375, 0.4, 0.3))

Composing Functions Together

Chain multiple effect functions into one:

// Compose: apply functions left to right
const compose = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x)

// Individual effects
const pluck = x => x.attack(0.001).decay(0.15).sustain(0)
const acid = x => x.lpf(sine.range(300, 2000).slow(2)).lpq(12)
const space = x => x.room(0.3).delay(0.2)

// Combine into one sound
const acidBass = compose(
x => x.s("sawtooth"),
pluck,
acid,
space
)

// Use like any other effect
acidBass(note("c2 c2 eb2 g2"))

Alternative: right-to-left composition (like math notation):

// Pipe: left to right (reads like a chain)
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x)

// Compose: right to left (like f(g(x)))
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x)

const pluck = x => x.attack(0.001).decay(0.12).sustain(0)
const filter = x => x.lpf(800).lpq(8)

// These are equivalent:
const sound1 = pipe(pluck, filter)      // pluck first, then filter
const sound2 = compose(filter, pluck)   // same: filter(pluck(x))

sound1(note("c2 eb2 g2").s("sawtooth"))

Conditional Effects

Apply effects based on conditions:

// Per-event probability using degradeBy
// 50% chance each note gets distortion
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(800)
.distort(brandBy(0.5).mul(0.5))

Or use sometimesBy for probabilistic effect application:

// sometimesBy: apply effect to some events
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(800)
.sometimesBy(0.5, x => x.distort(0.5))
// Either: blend between two effects based on random signal
// Use rand (per-event) or perlin (smooth) for variation
note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(rand.range(400, 3000))  // random filter per event
.room(rand.range(0, 0.4))

For more control, use chooseCycles or conditional stacking:

// Alternate between effect chains every N cycles
const bright = x => x.lpf(3000).room(0.4)
const dark = x => x.lpf(400).delay(0.3)

// Switch every 2 cycles
cat(
bright(note("c2 eb2 g2 bb2").s("sawtooth")),
dark(note("c2 eb2 g2 bb2").s("sawtooth"))
)
// When: apply effect only if condition is true
const when = (condition, fx) => x => condition ? fx(x) : x

const useDist = true
const useReverb = false

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(800)
.apply(when(useDist, x => x.distort(0.4)))
.apply(when(useReverb, x => x.room(0.5)))

Parallel Effect Chains

Run the same source through multiple effect paths:

// Parallel: split signal through multiple chains, then stack
const parallel = (...fxChains) => pattern =>
stack(...fxChains.map(fx => fx(pattern)))

// Three parallel paths
const wideChorus = parallel(
x => x.pan(0.2).detune(-10),           // left, detuned down
x => x.pan(0.5),                        // center, dry
x => x.pan(0.8).detune(10).delay(0.01)  // right, detuned up + tiny delay
)

note("[c3,e3,g3]")
.s("sawtooth")
.apply(wideChorus)
.gain(0.4)
// Frequency splitting: different effects for different bands
const multiband = (lowFx, highFx, crossover) => pattern =>
stack(
  lowFx(pattern.lpf(crossover)),
  highFx(pattern.hpf(crossover))
)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.apply(multiband(
  x => x.distort(0.6).gain(0.8),  // distort the lows
  x => x.room(0.4).gain(0.5),     // reverb the highs
  800                              // crossover frequency
))

Effect Modifiers

Functions that modify other effect functions:

// Intensify: scale an effect's parameters
const intensify = (fx, amount) => x => {
// Apply effect, then boost key parameters
const processed = fx(x)
return processed.gain(0.7 + amount * 0.3)
}

const baseSweep = x => x.lpf(sine.range(300, 2000).slow(4)).lpq(8)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.apply(intensify(baseSweep, 0.8))
// Repeat: apply an effect multiple times
const repeat = (n, fx) => x => {
let result = x
for (let i = 0; i < n; i++) result = fx(result)
return result
}

// Stack 3 layers of slight detune = thick chorus
const thicken = x => x.detune(rand.range(-8, 8))

note("[c3,e3,g3]")
.s("sawtooth")
.apply(repeat(3, thicken))
.gain(0.3)

Building an Effect Rack

Combine these patterns into a modular effect system:

// === EFFECT FACTORIES ===
const fx = {
// Filters
sweep: (min, max, speed) => x => x.lpf(sine.range(min, max).slow(speed)).lpq(8),
resonant: (freq, q) => x => x.lpf(freq).lpq(q),

// Time-based
echo: (time, fb, wet) => x => x.delay(wet).delaytime(time).delayfeedback(fb),
verb: (size, wet) => x => x.room(wet).roomsize(size),

// Modulation
trem: (rate, depth) => x => x.gain(sine.range(1-depth, 1).fast(rate)),
vib: (rate, cents) => x => x.detune(sine.range(-cents, cents).fast(rate)),

// Dynamics
drive: (amount) => x => x.distort(amount),
crush: (bits) => x => x.crush(bits)
}

// === UTILITY ===
const chain = (...fns) => x => fns.reduce((v, f) => f(v), x)

// === PRESETS (composed from factories) ===
const preset = {
lofi: chain(fx.crush(6), fx.resonant(2000, 2), fx.verb(0.5, 0.3)),
acid: chain(fx.sweep(300, 2500, 2), fx.drive(0.3)),
ambient: chain(fx.vib(3, 8), fx.echo(0.5, 0.5, 0.4), fx.verb(0.8, 0.5)),
aggro: chain(fx.drive(0.6), fx.resonant(1200, 12), fx.trem(8, 0.4))
}

// === USE ===
note("c2 c2 eb2 g2")
.s("sawtooth")
.apply(preset.acid)

Try swapping preset.acid with preset.lofi, preset.ambient, or preset.aggro.

Higher-Order Pattern Manipulation

Functions that transform entire patterns:

// Double: run pattern at two speeds
const double = (pattern, fx1, fx2) =>
stack(
  fx1(pattern),
  fx2(pattern.fast(2))
)

double(
note("c3 eb3 g3").s("sawtooth"),
x => x.lpf(600).gain(0.6),      // slow: dark
x => x.lpf(2000).gain(0.3)      // fast: bright
)
// Spread: play pattern across pan positions
const spread = (pattern, positions) =>
stack(...positions.map((p, i) =>
  pattern.pan(p).slow(positions.length).late(i / positions.length)
))

spread(
note("c4 e4 g4").s("piano"),
[0.1, 0.5, 0.9]  // left, center, right
).gain(0.5)
// Evolve: gradually morph between two effect states
const evolve = (fx1, fx2, cycles) => pattern => {
const progress = saw.slow(cycles)
return stack(
  fx1(pattern).gain(progress.range(1, 0)),
  fx2(pattern).gain(progress.range(0, 1))
)
}

note("c2 eb2 g2 bb2")
.s("sawtooth")
.apply(evolve(
  x => x.lpf(400),           // start dark
  x => x.lpf(3000).room(0.4), // end bright + reverb
  8                           // over 8 cycles
))

Part 7: Sliders as Variables

Storing Control Parameters

Sliders give you real-time control. Store them in variables to use the same control in multiple places:

// Shared controls
const filterCutoff = slider(800, 100, 4000)
const filterRes = slider(4, 0, 20)
const wetness = slider(0.2, 0, 0.8)

// Both bass and lead share the same filter control
stack(
note("c2 eb2 g2 bb2").s("sawtooth").lpf(filterCutoff).lpq(filterRes),
note("c4 ~ eb4 ~").s("square").lpf(filterCutoff.mul(2)).lpq(filterRes)
).room(wetness)

Move one slider, both sounds respond.

Control Objects

Organize controls into objects:

// Control surface
const ctrl = {
filter: slider(800, 100, 4000),
res: slider(4, 0, 20),
decay: slider(0.15, 0.05, 0.5),
room: slider(0.2, 0, 0.6)
}

// Use throughout
note("c2 c2 eb2 g2")
.s("sawtooth")
.attack(0.01)
.decay(ctrl.decay)
.sustain(0)
.lpf(ctrl.filter)
.lpq(ctrl.res)
.room(ctrl.room)

Mixing Sliders with Signals

Slider as base, signal as modulation:

const baseFilter = slider(600, 100, 2000)
const wobbleDepth = slider(400, 0, 1000)
const wobbleSpeed = slider(4, 1, 16)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(baseFilter.add(sine.range(0, wobbleDepth).fast(wobbleSpeed)))
.lpq(10)
._scope()

Three sliders control: base frequency, wobble amount, and wobble speed.

Sliders as Signal Parameters

Sliders can control signal behavior—use them inside .range(), .slow(), .fast():

// Slider controls the modulation speed
const speed = slider(2, 0.25, 8)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(300, 2000).slow(speed))
.lpq(8)
._scope()
// Slider controls the range maximum
const maxFreq = slider(2000, 500, 5000)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(200, maxFreq).slow(4))
.lpq(8)
._scope()

Sliders as Master Controls

One slider can control multiple parameters simultaneously—perfect for performance macros:

Intensity control — one slider affects filter, resonance, reverb, and volume:

const intensity = slider(0.5, 0, 1)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(intensity.mul(3000).add(200))
.lpq(intensity.mul(15))
.room(intensity.mul(0.5))
.gain(intensity.range(0.3, 1))
._scope()

Move one slider—everything responds together.

Speed master — control multiple LFO speeds in sync:

const speed = slider(2, 0.25, 8)

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(sine.range(300, 2000).slow(speed))
.pan(sine.range(0.3, 0.7).slow(speed.mul(2)))
.gain(sine.range(0.6, 1).slow(speed.div(2)))
._scope()

The filter, pan, and gain all modulate together but at related speeds (1x, 2x, 0.5x).

Wet/dry mix — one slider for all effects:

const wet = slider(0.3, 0, 1)

note("c4 e4 g4 b4")
.s("piano")
.room(wet)
.delay(wet.mul(0.5))
.lpf(wet.range(4000, 1000))

As wet increases: more reverb, more delay, darker filter.

Chaos control — add randomness progressively:

const chaos = slider(0, 0, 1)

note("c4 e4 g4 b4")
.s("piano")
.gain(perlin.range(0, chaos.mul(0.5)).add(0.7))
.pan(perlin.range(0, chaos).slow(2).add(0.5))
.detune(perlin.range(0, chaos.mul(30)))
._scope()

At 0: clean and stable. At 1: wild variation in volume, pan, and pitch.

Crossfade Between Signal Chains

Use a slider to blend between two parallel effect chains—like a DJ crossfader between “dry” and “wet” versions of a sound:

const mix = slider(0.5, 0, 1, 0.01)

const dry = note("c3 e3 g3").s("sawtooth").lpf(800)
const wet = note("c3 e3 g3").s("sawtooth").delay(0.5).room(0.7).hpf(400)

stack(
dry.gain(1 - mix),
wet.gain(mix)
)

Move the slider: 0 = fully dry, 1 = fully wet, 0.5 = equal mix.

As a reusable higher-order function:

// Crossfade factory: blend any two effect chains
const crossfade = (dryFx, wetFx, mix) => pattern =>
stack(
  dryFx(pattern).gain(1 - mix),
  wetFx(pattern).gain(mix)
)

const mix = slider(0.5, 0, 1, 0.01)

note("c2 e2 g2 c3").s("supersaw")
.apply(crossfade(
  x => x.lpf(600),                           // dry: filtered
  x => x.delay(0.4).room(0.6).distort(0.3),  // wet: effects chain
  mix
))

Now you can crossfade between any two effect chains by swapping the functions.

Equal-power crossfade — for smoother volume during the transition:

const mix = slider(0.5, 0, 1, 0.01)

const dry = note("c3 e3 g3").s("sawtooth").lpf(800)
const wet = note("c3 e3 g3").s("sawtooth").delay(0.5).room(0.7)

stack(
dry.gain(Math.cos(mix * Math.PI / 2)),
wet.gain(Math.sin(mix * Math.PI / 2))
)

Equal-power crossfade eliminates the volume dip at the midpoint—perceived loudness stays constant as you sweep.

Math with Sliders

Sliders support math operations just like signals:

OperationExampleResult
.add(n)slider(100).add(50)slider + 50
.mul(n)slider(0.5).mul(2000)slider × 2000
.div(n)slider(8).div(2)slider ÷ 2
.range(a,b)slider(0.5).range(200, 2000)map 0-1 to 200-2000

Combine them for complex mappings:

const macro = slider(0.5, 0, 1)

// Map one slider to multiple ranges
const filterFreq = macro.range(200, 3000)    // 0→200Hz, 1→3000Hz
const filterRes = macro.range(2, 15)         // 0→2, 1→15
const reverbAmt = macro.mul(0.6)             // 0→0, 1→0.6

note("c2 eb2 g2 bb2")
.s("sawtooth")
.lpf(filterFreq)
.lpq(filterRes)
.room(reverbAmt)
._scope()

One “macro” slider, but each parameter responds with its own range.


Part 8: Pattern Variables for Live Performance

The .p() System

Name your patterns so they can run independently:

// Each pattern runs independently
$: s("bd ~ bd ~").bank("RolandTR909").p("kick")
$: s("~ sd ~ sd").bank("RolandTR909").p("snare")
$: s("hh*8").bank("RolandTR909").gain(0.5).p("hats")
$: note("c2 c2 eb2 g2").s("sawtooth").lpf(700).p("bass")

Variables + Pattern Names

Combine your library with live pattern naming:

// Sound library
const env = {
pluck: x => x.attack(0.001).decay(0.12).sustain(0)
}
const acidBass = x => env.pluck(x.s("sawtooth").lpf(800).lpq(8))

// Drum kit
const kit = {
kick: s("bd").bank("RolandTR909"),
snare: s("sd").bank("RolandTR909"),
hat: s("hh").bank("RolandTR909")
}

// Live performance with named patterns
$: kit.kick.struct("x ~ x ~").p("kick")
$: kit.snare.struct("~ x ~ x").p("snare")
$: kit.hat.struct("x*8").gain(0.5).p("hats")
$: acidBass(note("c2 c2 eb2 g2")).p("bass")

Your library becomes your instrument. Change definitions, re-evaluate, hear the difference.

Muting and Global Effects

$: s("bd ~ bd ~").bank("RolandTR909").p("kick")
$: s("~ sd ~ sd").bank("RolandTR909").p("snare")
// Prefix with _ to mute:
_$: s("hh*8").bank("RolandTR909").gain(0.5).p("hats")
$: note("c2 c2 eb2 g2").s("sawtooth").lpf(700).p("bass")

// Apply to all patterns
all(x => x.room(0.2))

Part 9: Full Example — Library in Action

// === ENVELOPE LIBRARY ===
const env = {
pluck: x => x.attack(0.001).decay(0.12).sustain(0),
pad: x => x.attack(0.5).decay(0.3).sustain(0.7).release(1)
}

// === FILTER LIBRARY ===
const filter = {
acid: x => x.lpf(sine.range(300, 1500).slow(2)).lpq(10),
warm: x => x.lpf(1200).lpq(2)
}

// === SOUND LIBRARY ===
const sound = {
bass: x => filter.acid(env.pluck(x.s("sawtooth"))),
chords: x => filter.warm(env.pad(x.s("sawtooth"))).room(0.4)
}

// === DRUM KIT ===
const kit = {
kick: s("bd").bank("RolandTR909"),
snare: s("sd").bank("RolandTR909"),
hat: s("hh").bank("RolandTR909")
}

// === RHYTHMS ===
const rhythm = {
kick: "x ~ [~ x] ~",
snare: "~ x ~ x",
hat: "x*8"
}

// === CONTROLS ===
const ctrl = {
room: slider(0.15, 0, 0.5)
}

// === COMPOSITION ===
stack(
kit.kick.struct(rhythm.kick),
kit.snare.struct(rhythm.snare),
kit.hat.struct(rhythm.hat).gain(0.5),
sound.bass(note("c2 c2 eb2 c2 g1 g1 bb1 g1")),
sound.chords(note("[c4,eb4,g4]")).slow(2).gain(0.35)
).room(ctrl.room)

Everything is modular. Change any piece, the rest adapts.


Quick Reference

Variable Assignment Patterns

What to StoreSyntaxUsage
Notesconst mel = note("c4 e4")mel.s("piano")
Soundsconst kick = s("bd")kick.gain(0.8)
Envelopesconst pluck = x => x.attack(0.01).decay(0.1)pluck(note("c3"))
Filtersconst dark = x => x.lpf(600)dark(note("c2"))
Signalsconst sweep = sine.range(200, 2000).slow(4).lpf(sweep)
Rhythmsconst groove = "x ~ x [~ x]".struct(groove)
Controlsconst cutoff = slider(800, 100, 4000).lpf(cutoff)
Full soundsconst acid = x => x.s("saw").lpf(800)acid(note("c2"))

Common Envelope Presets

const env = {
  pluck: x => x.attack(0.001).decay(0.12).sustain(0).release(0.1),
  pad: x => x.attack(0.8).decay(0.3).sustain(0.7).release(1.5),
  stab: x => x.attack(0.02).decay(0.1).sustain(0.5).release(0.3),
  organ: x => x.attack(0.01).decay(0.1).sustain(0.9).release(0.1)
}

Common Filter Presets

const filter = {
  dark: x => x.lpf(600).lpq(2),
  bright: x => x.lpf(3000).lpq(4),
  acid: x => x.lpf(sine.range(300, 2000).slow(2)).lpq(12),
  wobble: x => x.lpf(sine.range(200, 1500).fast(4)).lpq(8)
}

Signal Types

SignalBehaviorUse Case
sineSmooth oscillationFilter sweeps, tremolo, vibrato
sawRamp up, resetBuildups, risers
triTriangle waveSmooth linear modulation
squareOn/offGating, rhythmic chops
randRandom per eventHumanization
perlinSmooth randomOrganic drift
brandBinary random (0/1)Coin-flip effects
brandBy(p)Binary with probability pSparse triggers, probabilistic gating

Signal Manipulation

MethodEffectExample
.slow(n)Oscillate over n cyclessine.slow(8)
.fast(n)Oscillate n times per cyclesine.fast(4)
.range(a, b)Map to value rangesine.range(200, 2000)
.add(n)Add offsetsine.add(500)
.mul(n)Multiply (scale)sine.mul(1000)
.segment(n)Sample to n stepssine.segment(8)

Common Signal Presets

const lfo = {
  // Speed variants
  glacial: sine.slow(16),
  slow: sine.slow(4),
  fast: sine.fast(4),

  // Ready-to-use
  filterSweep: sine.range(400, 2000).slow(4),
  wobble: sine.range(0, 1).fast(4),
  vibrato: sine.range(-10, 10).fast(6),
  tremolo: sine.range(0.4, 1).fast(8),
  autoPan: sine.range(0.2, 0.8).slow(2),
  riser: saw.range(200, 4000).slow(16),

  // Boolean signals
  coinFlip: brand,                    // 50/50 random
  sparse: brandBy(0.2),               // 20% chance
  mostly: brandBy(0.8)                // 80% chance
}

Where to Go Next