Strudel Bench · Part 3 of 5
Knobs in the code
A tour of the Compose page: three views on one source string that all write back through one door, and the four controls that only change what you hear.

On this page
The Compose page is one source string with three views on it. You can type in the source. You can drag a slider in the Mix card. You can hold alt and drag a number inside the code. You can click a chord button, click a step in a drum grid, or pick a sample and hear it before choosing. Every one of those is the same kind of edit: it goes into the file, it is unsaved until you save, it lands on one undo stack, and if the song is playing it re-evaluates in place so you hear it land.
The four things that do not work that way are named in the interface as “what you hear, not the song”: solo, mute, the A/B button and the preview level. They never touch the file.
That is the whole design. The two posts before this one covered the language the page edits and the words it accepts; this one is about the mouse.
The page, top to bottom#
- The song header and transport. Pick a song, press play. Pause remembers the cycle; stop forgets it. A readout shows the section, bar and chord under the playhead, and a small scope shows the peak in dBFS, held for a second, red at clipping. Space plays and pauses outside text fields.
- The Mix card, which is most of the page and most of this post: a song pane, an arrangement strip, a section pane, a harmony strip, an axes grid, a materials list and a phrase row.
- The song source, a code editor over the whole file, with a checkbox that puts a widget after every literal the language knows.
- The expanded Strudel, read-only, regenerated a third of a second after each edit: the plain Strudel the file reduces to, with the lines your last edit changed flashing.
- Change comments and Why it sounds this way: listening notes pinned to the transport, and the recorded reason behind every past change.
Controls on the literals#
The editor puts controls inside the code. A slider sits after a number, a menu after a sound name, a row of chord buttons after a progression. Holding alt and dragging a number changes it in place, at 200 pixels for its full range, and that works whether or not the controls are switched on.
But only for literals a schema describes. And the schema is a whitelist, which means the default is silence:
A literal without an entry here never gets a control, so a wrong range cannot be invented for it.
| Literal | Control | Why |
|---|---|---|
| .5 | slider | an axis, bounded 0 to 1 |
| saw | menu | a signal standing in for a number |
| .3 and .7 | sliders | bounds inherited from the axis |
| 8 | spinner | a floor but no ceiling |
| 'piano' | menu | the sounds actually loaded |
| true | toggle | a boolean |
| 2 | spinner | a floor but no ceiling |
| the note string | none | free text, written by hand |
The distinction between a slider and a spinner is the same principle one level down. A number with a known ceiling gets a slider. A number with only a floor gets a spinner, because a slider would have to invent a maximum, and inventing a maximum is lying about the language at sixty frames a second under someone’s cursor.
One number, and what it costs to be wrong about it#
The drum part in one section sits at density: .9. Drag it to .84, which is about twelve pixels
of mouse movement:
density .9 against .84. The clap crosses its threshold and leaves the part.
Twenty-eight onsets per bar become twenty-six, and the section’s energy drops from 59 to 57, because voices join a drum part as density rises and the clap’s threshold is 0.85. Six hundredths of a knob is a voice appearing or disappearing.
Fixing that in a text file costs a save and a listen. Fixing it with a drag costs nothing, and you hear it while the song is still playing.
The Mix card#
The card shows one section at a time: the one under the playhead, or the one you have pinned.
The arrangement strip is a block per section, as wide as the section is long, with one row per part shaded by how dense and how loud it is. Click a block to scrub to it. Pin it and it loops on its own, and play starts there. Each block has buttons to move it earlier or later, duplicate it, rename it, or remove it, and a lightning pick that applies one of the arrangement verbs from the previous post to the whole section. The gap between two blocks carries the transition: a riser of so many bars at the end of this section, a hit sample on the downbeat of the next, and a dropout or a filter sweep over the last bars.
The harmony strip shows the section’s key and one button per bar with the chord’s name over its
numeral. Each is a pick. A progression written with a hand-held @n repeat is shown read-only here,
because one bar in a picker cannot stand for a written repeat count.
The axes grid is a slider per axis per part, with an empty cell wherever that kind of part has no implementation for that axis. An axis whose value is a signal is drawn as a lane instead, the actual curve sampled across the section’s bars, and clicking it opens the same shape pick every continuous axis has: constant, ramp up, ramp down, wobble, drift, pulse, swell, each written centred on the current value. Under every row an event strip draws the part’s onsets across the bars, with a playhead sweeping over it while that section sounds.
Materials is one row per part for everything that is not an axis. A drum part shows its template as a pick, or, once you have chosen to write it out, as a step grid you click through rest, hit, accent and ghost, plus a sound pick per voice with a play button that auditions the voice alone. A bass or melody shows its note string with a piano roll under it that writes the string for you, a seed stepper for the generated line when there is no written one, a patch pick, and the follow checkbox. A pad shows its arpeggio. A sample part shows the file’s waveform with the region and the slice lines on it, drag to move a break, click to add one, a button per slice to hear it, and a tempo guess with two buttons: write this part’s bar count, or set the song’s tempo. Every row ends with a level slider, the volume control the axes deliberately lack, and a dB slot that the measure button fills.
Every pick on the page opens the same menu: a filter box, and a row per option with a play button that auditions it without selecting it. The kit menu plays every kit through the same two bars of material, and names the voices a kit lacks.
What you hear and what is written#
Every control on the page falls on one side of this line:
Solo, mute, A/B and the preview level change what you hear and never touch the file.
Everything else, every knob and every menu and every dragged number, is a source edit that goes through one undo stack.
Solo is a question about a mix, not a decision about a song. “Is the pad doing anything?” should not cost an edit, an undo, and a diff you have to remember to notice. Here it costs a click:
Soloing did not change the file. The isolated part is bit-identical to that part inside the song.
That bit-identical claim comes out of the structure. The function that arranges a song’s sections into one pattern takes an optional filter, and solo and mute are that function handed a filter. There is no second mixer. Because the solo path goes through the same arranger, the mixer cannot drift from the renderer. It is the renderer.
The file also stays a clean record of intent, which is what an agent editing it reads. A mixer that wrote solo state into the source would be quietly lying to the next request.
Two more things bypass the stack, and they are about where you are rather than what you hear: pinning a section and scrubbing the playhead. Nothing that changes the music escapes it.
Why a drum voice previews alone#
When you audition a single drum voice from the menu, it plays on its own rather than inside the part. The comment says why: the whole part would drown it, or not even contain it.
That second clause is literal. Voices join a drum part at thresholds, and the clap’s is 0.85:
The clap, the part that does not contain it, and the part that does. In the middle clip there is nothing to preview.
Preview the clap “in context” at density: .6 and you would hear silence, and conclude the sample was
broken.
Words, and whether they worked#
The phrase row under the materials is where the vocabulary lives on the page. Type a word and it becomes a pill, but only if it is a word the language knows; anything else stays red and never becomes one. A part’s name is a pill too, and it scopes the words after it to that part. Apply rewrites the numbers and reports what it did, saturation and refusals included.
Verify does something a text file cannot. It renders the section as it stands, applies the words, renders it again, measures both with the same analyser the command line uses, and shows a table: one row per axis you asked for, its metric, the before and after values, and whether the number moved the way you asked. Then it applies the edit, puts both renders on a pair of play buttons, and you listen.
The verdict is a sign test, one row per axis, and it is deliberately not a stronger claim than that.
The word punchier moves four axes at once:
Requested: articulation +0.40, weight +0.15, drive +0.20, space -0.15
Render: crest 5.11 -> 4.27 lowRatio 0.518 -> 0.482 centroidHz 573 -> 449
Result: articulation NOT verified (crest 5.11 -> 4.27), weight NOT verified,
drive code-checked: onset count unchanged (15/cyc), positions not measured,
space verified (tail 0.022 -> 0.016)
Articulation is judged by crest factor. Drive re-places where the onsets fall. So drive moved the measurement that articulation was being scored on, and articulation reads as a failure. Listen to it. It is unmistakably punchier:
Two bars of drums, before and after the word punchier. Measured 2026-09-13 with the command-line verify.
A tool that said “verified” there would be more pleasant and less useful. Four axes moved, three of them cannot be separated by these metrics, and the strongest available claim is about one of them. “Verified directionally” is the ceiling written into the project’s own instructions, and this is what enforcing it costs. Two caveats: the page’s Verify measures the whole section mix and applies a plain sign test, while the command-line version renders one part alone and adds a two percent floor, so the same request can score differently in the two places.
The same offline renderer is behind the measure button, which renders the section and then each part alone and writes, next to every level slider, how many decibels under the mix that part sits. That turns “is the choir too loud?” into a number, and the mixing post is about what to do with it.
Notes you pin while listening become the next request#
Press play. The transport position follows the clock, so wherever the slider is is where your ear is. When something bothers you, type a sentence and press a button. The comment pins at that exact cycle, tagged with the section it fell in. Drag the dot to re-pin it, because the ear reacts a moment late and “actually it was a beat earlier” should be a drag rather than a retype.
Then one button assembles a request: the section map with bar counts, the numbered notes rendered as
cycle 26.5 (will, bar 2 of 8): the piano lost its sustain, and the full source of every section
anyone pinned.
Press the button and those five dots become this, generated by the page’s own request builder against the song as it stood on 2026-09-13:
The request those five comments produce (78 lines)
Song: songs/arrival.strudel
Sections: void (1 bars from cycle 0), signal (8 bars from cycle 1), approach (8 bars from cycle 9), contact (8 bars from cycle 17), will (8 bars from cycle 25), plea (8 bars from cycle 33), threshold (12 bars from cycle 41), after (6 bars from cycle 53)
I listened and pinned each note at the cycle the playhead was on. A note is about what I heard there: that exact
spot, the section it is in, or what led into it just before. Please make these changes and keep everything else as it is:
1. cycle 0.4 (void, bar 1 of 1): the whistle is still too loud here
2. cycle 6.0 (signal, bar 6 of 8): the intro is too long
3. cycle 10.2 (approach, bar 2 of 8): the piano pops behind every key press
4. cycle 26.5 (will, bar 2 of 8): the piano lost its sustain, every note dies early
5. cycle 41.3 (threshold, bar 1 of 12): the piano still pops, and it should sound the same all the way through
Current source of the parts around those spots:
```
song({ bpm: 64, key: 'D:minor', seed: 41, kit: 'RolandTR808' }
section('void', 1, { role: 'establish', progression: 'i',
pad: { ...organ, density: .2, brightness: .1, space: 1 },
pad2: { ...strings, density: .2, brightness: .15, weight: .8, register: .2, level: .5 },
}),
section('signal', 8, { role: 'establish', progression: 'i VI',
melody: { ...signal, density: .3 },
pad: { ...organ, density: .2, brightness: .15 },
pad2: { ...strings, density: .4, brightness: .25, register: .3, level: .6 },
pad3: { ...choir, density: .2, brightness: .25, level: .25 },
}),
section('approach', 8, { role: 'develop', progression: 'i VI VII',
drums: { ...pulse, density: .25 },
bass: { ...pedal, density: .4, notes: '0 0' },
melody: { ...theme, density: .4, brightness: saw.range(.45, .7).slow(8) },
melody2: { ...signal, density: .3 },
melody3: { ...winds, density: .4 },
pad: { ...strings, density: .55, brightness: saw.range(.25, .45).slow(8), register: .4 },
pad2: { ...organ, density: .4 },
pad3: { ...choir, density: .4, brightness: .35 },
fx: { riser: 4 },
}),
section('will', 8, { role: 'develop', key: 'D:phrygian', progression: 'i II i II',
drums: { ...pulse, template: 'minimal', density: .25, space: .6 },
bass: { ...pedal, sound: 'didgeridoo:8', density: .4, notes: '0 ~', register: .15, brightness: .15, weight: 1, aggression: .55, space: .4 },
melody: { ...theme, notes: '0 ~ ~ ~ 1 ~ 0@2 ~ ~ ~ ~ 1 ~ 0@2', density: .3, register: .35, brightness: .25, space: .9 },
melody2: { ...signal, density: .4, level: .75, brightness: .6, space: .6 },
melody3: { ...bells, density: .5, level: .6, register: .2, space: .7 },
pad: { ...organ, density: .5, brightness: .15, weight: .95, space: .6 },
pad2: { ...strings, density: .3, brightness: .2, register: .2, level: .6, space: .5 },
}),
section('plea', 8, { role: 'develop', key: 'F:major', progression: 'I V vi IV',
drums: { ...pulse, density: .3, space: .95 },
bass: { ...pedal, density: .4, notes: '0 4', weight: .7, brightness: .3 },
melody: { ...theme, density: .5, brightness: saw.range(.45, .8).slow(8), register: .65, width: .6 },
melody2: { ...harp, density: .5, brightness: saw.range(.5, .8).slow(8) },
melody3: { ...winds, density: .5, level: .5, notes: '~ ~ 4@4 ~ ~ 5@4 ~ ~ 7@2' },
melody4: { ...signal, density: .25, level: .4 },
pad: { ...strings, density: .6, brightness: saw.range(.35, .6).slow(8), register: .5, width: .9 },
pad2: { ...choir, density: .6, brightness: .5, level: .5 },
pad3: { ...organ, density: .4, brightness: .3, level: .5 },
fx: { riser: 4 },
}),
section('threshold', 12, { role: 'climax', progression: 'i VI III VII',
drums: { ...pulse, density: .55, weight: .9, variation: .3 },
bass: { ...pedal, density: .75, notes: '0 0 4 7', weight: 1, brightness: .25, aggression: .55 },
melody: { ...answer, density: .6, brightness: .75, register: .65, width: .8, level: 1.2 },
melody2: { ...signal, density: .6, level: .8, brightness: .7 },
melody3: { ...bells, density: .6, level: .7 },
melody4: { ...harp, density: .5, brightness: .8, level: .6 },
melody5: { ...winds, density: .6, level: .55, notes: '~ ~ 7@4 ~ ~ 5@4 ~ ~ 4@2' },
melody6: { ...theme, sound: 'glockenspiel', register: .9, density: .3, level: .45, space: .95, width: .9 },
pad: { ...strings, density: .85, brightness: .55, weight: .8, width: .95, level: 1.1 },
pad2: { ...organ, sound: 'pipeorgan_loud', density: .8, brightness: .35, weight: 1, level: .6 },
pad3: { ...choir, density: .8, brightness: .6, level: .55 },
pad4: { ...strings, sound: 'vibraphone_bowed', density: .4, register: .85, brightness: .8, level: .35, arp: 'up' },
fx: { impact: true },
}),
```That works because of a rule in the request builder:
// A note in a section's first bar also quotes the section before it: the ear often reacts a moment late.
A note landing in the first bar of a section quotes the previous section’s source too. Because what bothered you probably started before you noticed it.
What the public site cannot do#
The same page is deployed on GitHub Pages with no server behind it, and four things there need the local one, because they touch disk: saving to the song file, the change comments card, importing a sample file, and the Samples workshop page where a pack’s named regions are defined. Saving goes to the browser’s local storage instead. And the page wants a desktop window: under about 60rem it shows a warning row rather than hiding anything, because the Mix card does not fit.
Everything else above still works online, because it all runs in the browser anyway: play, the Mix card, Verify, measure, the MP3 export (rendered offline in the browser, one cycle at a time, and encoded there too), the Strudel export, and the Link button, which packs the whole song into the URL with the browser’s own compression API so nobody needs a paste bin.
Open the editor on that sectionHide the editor
The live editor needs a wider screen than this one. Open it in a new tab ↗
The next post is about the newest part of the page, the measure button and everything it led to: mixing as a scene.