Skip to content
Always Shippable

Strudel Bench · Part 1 of 5

A language on top of Strudel

Strudel is a fine instrument and a poor score. What strudel-bench adds: named sections, parts with baselines, twelve knobs per part, a layer you can decompile.

Updated 18 min read
A compact card of values fanning out into a tall stack of code lines, with an arrow leading back
On this page

Strudel is a live-coding music language that runs in a browser. Eight lines of it make a complete piece:

// generated by gen/euclid.mjs --seed=3  (128 bpm, E:phrygian, RolandTR808)
setcps(0.533)
stack(
  s("bd(3,8)").bank("RolandTR808"),
  s("sd(2,8,1)").bank("RolandTR808").gain(.8),
  s("hh(5,8)").bank("RolandTR808").gain(.5),
  n("3 5 0 1 4 5 1 2").scale("E:phrygian").s("sawtooth").lpf(1200).decay(.15).sustain(0).gain(.6)
)

Four bars, rendered straight from the file above.

Open these eight lines on strudel.cc, press play, and change a number to hear what it does.

Four parts: a kick on a three-against-eight Euclidean rhythm, a snare, hats, and a bass line in E phrygian. You can read the whole thing at a glance, which is why it is worth building on.

This series is about strudel-bench, a small system that sits on top of Strudel so that a long, through-composed piece can be written, read and edited without becoming a wall of chained calls. This post is the tour: what Strudel is, where Strudel stops helping, and what the layer on top actually adds. The rest of the series covers the vocabulary that edits a song in words, the editor, how it mixes, and one finished piece, taken apart.

Strudel in sixty seconds#

Strudel is a JavaScript port of TidalCycles, the Haskell live-coding environment. It runs in a browser, it makes sound through Web Audio, and you can edit it while it is playing. A genuine port rather than an homage: it uses the same mini-notation grammar Tidal does.

A pattern is a pure function from a span of time to a list of events. Nothing is stored. You ask a pattern what happens between cycle 0 and cycle 4, and it computes the answer. This is why the whole system can be tested headlessly with no audio hardware: a checker just queries.

Mini-notation is a string DSL for rhythm, parsed inside double-quoted strings. s(“bd sd”) is two events splitting one cycle. ~ is a rest, [a b] subdivides a slot,

<a b>

alternates one per cycle, a*2 doubles the speed, a@2 makes a slot twice as wide, and bd(3,8) distributes three hits across eight positions as evenly as possible.

The unit of time is the cycle. A cycle is not intrinsically a bar. That is a convention you adopt, and Strudel’s own docs show it as setcpm(140/4), which is 140 BPM in 4/4 once you divide out the beats. cps is cycles per second, and the default is 0.5, so one cycle every two seconds.

One cycle, one bar. strudel-bench adopts that convention and makes it structural. A meter of 4/4 means sixteen sixteenth steps in one cycle, and bpm: 64 becomes cps of 64/60/4. So when a song says it is 59 bars, it is 59 cycles.

Everything past that is combinators. .jux(rev) plays a reversed copy in the other ear.

.sometimesBy(0.4, x => x.ply(2))

doubles up some events. Continuous signals are patterns too, so any control can be modulated: .lpf(saw.range(300, 8000).slow(8)) sweeps a filter over eight cycles.

Strudel also already has arrangement. arrange([4, patA], [2, patB]) does what you would hope, so this is not a story about a library that could not sequence.

To go further, the Strudel workshop starts from zero and plays every example in the page.

Where Strudel stops helping#

The problem is not sequencing. It is per-part state.

There is no baseline. Strudel has no concept of what a part normally sounds like. Every part in every section is written from scratch. Nothing in the language can say “the same pad, a shade darker.”

Later calls silently replace earlier ones. .lpf(2000).lpf(800) is 800. The first call is gone, with no warning. So you cannot layer two independent adjustments onto one part without knowing exactly what else already wrote that control.

Nothing is named. A live-coding session is one expression. A long piece is dozens of them, and they are positional.

One musical idea is N edits. “The bowed strings should be a shade darker everywhere” is one number if parts have baselines. Written out, in a 59-bar piece where the same bowed strings appear in every section, it is eight separate filter cutoff values, each already different because each was adjusted for its own section.

None of this makes Strudel badly designed. It is built for a four-bar loop you are editing live in front of an audience, and it is excellent at that. A fully composed piece of music is not what it was built for.

What strudel-bench is#

A page and a set of scripts that read the same song files. The page has three views:

  • Compose is where a song is written and played: the editable source, the plain Strudel it expands to (regenerated as you type), and a Mix card where every slider, menu, chord button and word is a source edit. The editor post walks through it.
  • Examples is a scrollable showcase of playable cards, one per axis, word, modifier, harmony form, material and section edit, each with before-and-after variants you can play and an editable pane.
  • Samples only runs against the local server, because it writes to disk, and it is where your own audio becomes something a song can ask for by name. Drop a folder of wav files into samples/user/<pack>/ and each one is a sound. Point the page at a longer file and you can mark regions of it instead: drag the region edges over the waveform, drag the slice breaks (they snap to a beat grid), take the tempo the page guesses from the onsets, and save the result as a name. loop-kick becomes the first sixteenth of a two-bar loop, and from then on any song that declares that pack can write sample: { sound: 'loop-kick' }.

That last view is why the local copy is the one I work in. The thing on the other side of the song file is usually an agent: I describe a change, it edits songs/<name>.strudel, the page notices the file changed and re-evaluates in place, and the music keeps playing while the edit lands. An agent can write loop-kick into a part. It cannot open a wav file and find where the kick is. Doing that once, by ear, with a mouse, turns a sound I own into a name an agent can write.

Sections and parts#

The layer strudel-bench adds comes down to one idea: a piece is a list of named sections, each holding parts, each part described by where it sits on a fixed set of perceptual axes.

The examples page has a playable card for every label in this map:

The shape of a song file, from the song call down to a single partA song call carries the tempo, key, kit, meter, seed and room, and holds a list of sections. A section has a name, a bar count, a key and a progression, and holds parts. A part is one of eight kinds and carries twelve axes, the material that fills them, and its mix routing. Words like darker and punchier are how the axis numbers get written. The section closes with its transitions into the next one.

Written out, one section of a real piece looks like this.

song({ bpm: 64, key: 'D:minor', seed: 41, kit: 'RolandTR808', packs: ['rooms'],
       room: { ir: 'hall', size: 3.2, damping: 4500 } }, [
  section('contact', 8, { role: 'climax', progression: 'i VI III VII',
    drums:   { ...pulse, density: .5, weight: .8 },
    bass:    { ...pedal, density: .75, notes: '0 0 4 0', weight: 1 },
    melody:  { ...theme, density: .55, brightness: .65, width: .6, level: 1.1 },
    melody2: { ...signal, density: .5, level: .7 },
    pad:     { ...strings, density: .8, brightness: .5, weight: .7, width: .75 },
  }),
])

That is five of the eleven parts in the first climax of a piece called arrival, the one the last post in this series is about. pulse, theme, signal and strings are ordinary JavaScript objects declared once at the top of the file and spread into every section that uses them, which is where the baseline lives: theme is the piano, and this section asks for it a little denser and brighter than usual.

Open the live editor on this sectionHide the editor

The live editor needs a wider screen than this one. Open it in a new tab ↗

Drag a slider or edit the source. Nothing you do here is saved. Open arrival.strudel, section contact in strudel-bench ↗

Each part is an instance of one of eight kinds. Four are the rhythm section and the bed: drums (a kit on a step grid), bass, melody and pad (the chord, voiced). One is transition material: fx, a riser into the next section or an impact on the downbeat. Three arrived later and are about bringing your own sound in: sample (a trimmed region of any file, sliced and re-sequenced), perc (one bare sound on a written rhythm) and raw (a plain Strudel pattern, placed inside a section so the tooling can see it). A section is not eight slots. It can hold as many parts of a kind as it wants, numbered: the biggest section of arrival runs a melody through melody6 and a pad through pad4, and the whole piece is 62 parts across 8 sections.

Every axis value is a number from 0 to 1, and 0.5 means the part’s baseline.

Not everything in those objects is an axis. sound, notes and level are material: literal values rather than positions on a scale. That is why level: 1.1 above is legal and an axis at 1.1 would not be. Level is a plain gain multiplier applied after every axis has had its turn, which is also why the axes have no volume of their own. Material has grown into a long list, and most of it belongs to later posts: written rhythm grammars, patches, harmony, and the mixing keys (position, room, duck, humanize) that the mixing post is entirely about.

Twelve axes, and the holes in the grid#

Which of the ninety-six layer and axis combinations are implementedSixty-four of ninety-six cells are implemented and thirty-two are deliberately empty. Brightness and space are implemented on all eight layers. The three transition and bring-your-own-sound layers (fx, sample, raw) implement only those two plus weight and width, which accounts for twenty-four of the holes.
Implemented cells per layer
LayerImplemented axes (of 12)
drums11
bass11
melody10
pad10
fx4
sample4
perc10
raw4
Filled cells are implemented; dashed cells are documented no-ops. Counted 2026-09-19, from the code, so it moves when a layer is added.

The twelve are density, drive, brightness, weight, space, articulation, aggression, groove, variation, organicness, width and register. They split two ways.

By how you verify them: four are direct (brightness really is the spectral centroid in hertz, and you can measure it), six are proxies (space is measured as how long energy persists after an onset, which is a stand-in for reverb, not reverb itself), and two can only be checked by reading the event stream. Drive moves which sixteenth a hit lands on without changing how many there are, so no audio metric moves at all.

By what they can accept: structural axes take a number and nothing else, because they decide the grid before any pattern exists and there is nothing for a signal to be sampled at. Continuous axes take a number, a Strudel signal, a ramp(a, b) across the section, or one of the named movements (wobble, drift, pulse, swell) that already know how long the section is.

The empty cells are on purpose. Drums have no register, because a kit has no pitch. Bass has no width, because widening the low end is a mix mistake. A sustained pad has no groove, because there is no offbeat to swing. A riser, a sliced sample and a raw pattern implement only the four cells that make sense for a sound whose rhythm is already decided.

An unimplemented axis is accepted and does nothing, visibly, which is what keeps the holes from being traps. Set register: .9 on drums and it is not an error. The value goes into the file, the checker prints it back, and the audio is byte-identical to leaving it out. The alternative is to pitch the samples up to honour the request, and that would be a guess.

Why 0.5 is nothing#

It would be easy to make 0.5 mean “apply the baseline value,” which sounds the same and is not. Here is the primitive:

if (typeof value === 'number') {
  if (value === 0.5 || (noopBelow && value <= 0.5)) return pat;
  return pat[name](fn(value));
}

At exactly 0.5 it returns the same object. Not an equivalent pattern, not the baseline re-applied. The same one. So there is no rounding and no “close enough”: an axis sitting at its baseline cannot possibly have done anything.

That matters because it makes the declarative file a description rather than a transformation. Writing out the values a part already has changes nothing, so edits do not accumulate error, and any axis you leave out is exactly the same as writing it at 0.5.

It is also enforced by test, for the four core layers plus fx and sample: each layer is built twice, once with no axes and once with one axis at 0.5, and the two event streams must be byte-identical. The complement asserts that every one of those cells does move something at 0 or at 1, so a cell that quietly does nothing anywhere fails the suite. The two newest layers, perc and raw, mostly borrow their cells from drums and sample by object identity rather than carrying tests of their own, which is the footnote to “enforced”.

Order matters#

Remember that a second .lpf() replaces the first. Now put two axes on the same part that both want to touch the filter cutoff. Brightness sets it. Weight scales it. If weight runs second and sets rather than scales, brightness is silently erased: a knob that does nothing, and only when another knob is turned.

So a cell can say “multiply this control” as well as “set it,” and the cell that sets always runs before the cell that scales. Both of these live in the same stage, so it is an ordering question inside one stage rather than between two. Run the same pair in the other direction and weight is the one that contributes nothing:

The seven pipeline stages, and what happens when two cells inside one of them run in the wrong orderAdapters run in a fixed seven-stage order, and within a stage in a fixed order too. Reverse the two cells and the cutoff lands at 1050.61 Hz, exactly where brightness alone would put it, because the setting call replaced the scaling one.
Bass filter cutoff under each ordering, recomputed 2026-09-19
PathCutoff (Hz)
baseline400
brightness .8 alone1050.61
weight .8 alone340
declared order, brightness then weight893.02
reversed, weight then brightness1050.61

Adapters run in fixed phases, because transformations do not commute. Seven of them, in this order: structural, timing, pitch, articulation, spectral, spatial, level. Structural runs first because it builds the material everything else transforms, over a plain object, before any pattern exists. Level runs last so nothing can undo the balance after it.

A cell declares which phases it participates in, and declaring a phase that does not exist is an error rather than a silent no-op.

The layer can be decompiled#

song() and section() are ordinary functions that call Strudel to build patterns at runtime. There is no template, no intermediate representation, no generated file. So “what Strudel does this actually become?” has no answer you can read.

Unless you instrument the call. For the duration of one evaluation, the dump replaces every Strudel core function and every method on the pattern prototype with a wrapper that records what it was called with, builds a map from each pattern object to the call chain that produced it, prints the chains, and then restores every original.

The arithmetic operators are non-configurable getters, so the wrapper never sees which one it is. It recovers them by probing the closure with 7 and 3 and looking the answer up in a table. If it comes back 10 it was addition, 21 multiplication, 4 subtraction.

Here is the intro of the demo song, which is four lines:

section('intro', 4, { role: 'establish',
  drums: { density: .3, space: .6 },
  pad:   { space: .8, articulation: .2, brightness: .35 },
}),

And here is what dump prints for it:

setcps(0.5)

const intro_drums = stack(
  s("bd")
    .struct("x ~ ~ ~ x ~ ~ ~ x ~ ~ ~ x ~ ~ ~")
    .bank("RolandTR909")
    .pan(0.5),
  s("sd")
    .struct("~ ~ ~ ~ x ~ ~ ~ ~ ~ ~ ~ x ~ ~ ~")
    .bank("RolandTR909")
    .pan(0.5)
)
  .room(0.08)
  .size(0.4)
  .orbit(1)

const intro_pad = n("<0 5>")
  .scale("C4:minor")
  .add(note(0))
  .add(note("<0 0>"))
  .add(note("<[0,3,7] [0,4,7]>"))
  .s("sawtooth")
  .lpf(1200)
  .attack(0.15)
  .release(0.6)
  .room(0.3)
  .size(0.6)
  .gain(0.45)
  .pan(sine.slow(8).range(0.4, 0.6))
  .vib(0.4)
  .vibmod(0.15)
  .clip(1.12)
  .attack(0.42)
  .release(1.44)
  .lpf(701.029)
  .room(0.66)
  .size(0.81)
  .orbit(2)

stack(intro_drums, intro_pad)

Thirty-seven lines, and they play. Here is the same four bars, rendered from each:

Four lines of sections and axes, and the thirty-seven lines of Strudel they expand to.

They are the same music, and that is checkable rather than a matter of taste: the two files produce identical event streams, 36 events each, matching on every onset, duration, sound and note. The expanded form is not quite bit-identical audio, because the dump prints numbers to six significant figures, which moves a filter cutoff by a few parts in ten million.

The expansion is the literal call sequence, not a minimised one, which is why the pad above sets .lpf(1200) and then .lpf(701.029) a dozen lines further down: the baseline, then the axis overriding it. No human would write that. It expands hand-written code too, so your own names are inlined away.

The abstraction is falsifiable. You can ask what brightness: .65 did and get a number, you can check that nothing is happening behind your back, and you can paste the result into strudel.cc and walk away. The page’s export button ships exactly this, with the sample-pack preamble strudel.cc needs prepended. It is a layer on top of Strudel, not a replacement for it.

The layer does not forbid raw Strudel#

The 59-bar piece is not purely declarative. Alongside the sections there are eleven hand-written plain-Strudel textures, gong swells and a wind bed and a low drone, stacked in with Strudel’s own arrange.

The declarative half models what it can model. Everything else stays raw, side by side, in the same file. The mixer’s solo button, the dump and the section loop see only the parts, which makes a useful diagnostic: a sound that survives soloing is in the layers, and one that vanishes is in the textures. The checker’s load count is the one tool that sees both, and it says so on every section header: ~37 voices at once (3 outside the parts).

For a texture that belongs to one section rather than the whole piece there is now a middle path, the raw part, which puts a plain pattern inside the section so that mute, solo, check and dump all see it.

Reading a song without playing it#

One command tells you what you have. This is the checker on the small demo song that ships with the page, trimmed to two of its three sections:

  sounds
    RolandTR909_bd:0         Bassdrum-01.wav (4 variants)
    RolandTR909_sd:0         naredrum.wav (16 variants)
    piano:0                  pitched, 29 samples A0..C8
  [0-4) intro (establish)  ~6 voices at once
    harmony C:minor  i VI -> Cm Ab
    drums       6/cyc  density=0.3 space=0.6             sparse
    pad         3/cyc  space=0.8 articulation=0.2 brightness=0.35   dreamy, dark
  [4-12) verse (develop)  ~6 voices at once
    harmony C:minor  i VI -> Cm Ab
    drums      15/cyc  density=0.6 groove=0.6
    bass        4/cyc  weight=0.7 register=0.3            massive
    melody    5.5/cyc  density=0.5 brightness=signal sound=piano follow=true phrase=2 notes=0 2 3@2 ~ 4 3 2 0 2 3@2 ~ 5@2 4
    pad         3/cyc  space=0.6
    fx          2/cyc  riser=2
  arc: intro 9 | verse 29.5 | drop 59

Every section, every part, onsets per cycle, every axis value, the chords each section actually resolved to, the file behind every sound name, and a load figure per section. The words at the end of a part’s line are the vocabulary run backwards: dreamy, dark is what the values say, in the same vocabulary the next post uses to change them. It runs in Node, with no audio, in a second or two for a song this size and about a quarter of a minute for the 59-bar one.

It also refuses things. A typo in an axis name is not a silent no-op, it is a build error that names both lists it would have accepted:

unknown key "brightnes" on drums in section "a" (axes: density, drive, brightness, weight,
space, articulation, aggression, groove, variation, organicness, width, register;
material: template, sounds, fill, level, position, duck, ...)

A sound name the loaded packs do not have fails the same way. That single behaviour is what makes a declarative layer safe to work in quickly, and it is why the checker rather than the test suite is the inner loop.

A second script, lint, applies musical rules to the same table: a climax exists and is the most energetic section, no two consecutive sections are identical, no section stacks two raw sawtooths, every melody and bass writes its own notes rather than playing a seeded default, every axis is in range, and no section asks for more voices at once than a laptop’s audio thread can render. Warnings for taste, errors for the impossible.

The last line it prints is the arc:

arc: void 1.1 | signal 4.1 | approach 21.6 | contact 57.6 | will 14.4 | plea 37 | threshold 67.3 | after 7.4
The energy of each of the eight sections across 59 barsEnergy climbs to 57.6 at the first climax, drops to 14.4, then peaks at 67.3 in the twelve-bar threshold section before falling away. The two dips are as deliberate as the peaks.
Section energy, in onsets per cycle summed over parts and scaled by level
SectionBarsRoleEnergy
void1establish1.1
signal8establish4.1
approach8develop21.6
contact8climax57.6
will8develop14.4
plea8develop37
threshold12climax67.3
after6release7.4
Energy is onsets per cycle summed over a section's parts, each scaled by its level. Read from the file on 2026-09-19; the piece is still being worked on.

Here is what the two ends of that line sound like: one bar of void, then four bars of threshold.

One bar of void, then four bars of threshold.

Two melodies over the same four chords, and one word separates them. The piano is told follow: true, so its line is re-rooted on each chord as the harmony moves. The handchimes are told follow: false, so they play the same three notes bar after bar, indifferent to the chords going past.

The piano follows the harmony. The handchimes refuse to.

Try it. The pane on the right is the expanded Strudel, and it updates as you type.

Open the live editor on the demo songHide the editor

The live editor needs a wider screen than this one. Open it in a new tab ↗

Change a value on the left and watch the expanded Strudel on the right follow it. Open demo.strudel, section drop in strudel-bench ↗

The next post is about the layer above this one: a vocabulary of English words that move these numbers, so you can say darker instead of brightness: .2.