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.

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-kickbecomes the first sixteenth of a two-bar loop, and from then on any song that declares that pack can writesample: { 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:
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 ↗
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#
| Layer | Implemented axes (of 12) |
|---|---|
| drums | 11 |
| bass | 11 |
| melody | 10 |
| pad | 10 |
| fx | 4 |
| sample | 4 |
| perc | 10 |
| raw | 4 |
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:
| Path | Cutoff (Hz) |
|---|---|
| baseline | 400 |
| brightness .8 alone | 1050.61 |
| weight .8 alone | 340 |
| declared order, brightness then weight | 893.02 |
| reversed, weight then brightness | 1050.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
| Section | Bars | Role | Energy |
|---|---|---|---|
| void | 1 | establish | 1.1 |
| signal | 8 | establish | 4.1 |
| approach | 8 | develop | 21.6 |
| contact | 8 | climax | 57.6 |
| will | 8 | develop | 14.4 |
| plea | 8 | develop | 37 |
| threshold | 12 | climax | 67.3 |
| after | 6 | release | 7.4 |
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 ↗
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.