Skip to content
Always Shippable

Strudel Bench · Part 2 of 5

Say darker

Twenty-nine English words, each a set of deltas on a song's numeric axes: how they apply, what they refuse, what saturates, and how much reads back.

Updated 14 min read
The word darker pushing four sliders down, two still falling and two already at the floor
On this page

Here is the same two bars of music three times. Between the first and the second I typed one word. Between the second and the third I typed the same word again.

Two bars each. The command between every pair is identical.

The first one does a great deal. The second does almost nothing, and it is loud about why.

The previous post was about the layer that turns a piece of music into named sections and parts, each sitting at some position on twelve numeric axes. This post is about the layer above that: a vocabulary of English words that move those numbers, the words that shape a number over time, the verbs that rearrange a whole section, and what keeps a deliberately small natural-language surface working.

The whole language is a table#

A word is not part of a song file. It is an edit to one:

A phrase, the deltas it stands for, and the numbers in the song file they land onThe phrase much darker resolves to brightness minus 0.60 and register minus 0.20. Those deltas are added to the brightness value written on every part in the named section, so the melody's 0.7 and the pad's 0.5 both move. The phrase itself is not written to the file; only the changed numbers are.

Not a parser. Not a model. A handful of JSON files.

"dark":   { "brightness": -0.30, "register": -0.10 },
"punchy": { "articulation": 0.40, "weight": 0.15, "drive": 0.20, "space": -0.15 },
"sparse": { "density": -0.35 }

Twenty-nine control words, plus eight mood words that work the same way and are documented as lower confidence. Ten modifiers, which are a multiplication table: slightly is 0.5, much is 2, extremely is 3, and more is 1, a deliberate no-op that exists so “more punchy” reads naturally. less is not a modifier at all; it is a separate branch that flips the sign of the whole vector.

Every word in the vocabulary and the axes each one movesThe table is sparse: 73 of a possible 348 word-and-axis combinations carry a value, and most words touch one or two axes. No control word's largest move is on register or on variation, and mood words are never read back, which is why neither axis can ever be spoken. The four depth words added for the mixing pass are small and all four are mute.
Vocabulary size
TableWordsNon-zero cellsPossible cells
control words2973348
mood words83096
Generated from the vocabulary files, so it cannot drift from them. Counted 2026-09-19.

Seventy-three numbers in a grid of 348. Five words touch exactly one axis. One word, dreamy, touches five. No delta anywhere in the table is larger than 0.40. The gentlest entries are the four depth words the mixing tools needed (closer, forward, farther, background), which move brightness, space and weight together by a fraction of what the older words do.

Deltas, not states#

darker does not mean “set brightness to 0.2.” It means “subtract 0.30 from wherever brightness currently is.”

That is what makes it feel like talking rather than filling in a form. You do not need to know the current value to ask for less of something, which is exactly the position you are in when you are listening rather than reading.

Consequently, a part that never mentioned an axis is treated as sitting at the baseline of 0.5, so a single word can insert properties that were not there. And the result is clamped to the range, at apply time, with the clamp reported rather than hidden.

Saying it twice#

The same command applied twice, and how much of it landed each timeBoth commands ask for the same 0.60 reduction. The first lands almost in full and drops the measured centroid by 89 percent. The second has nowhere to go and moves it by 5 percent.
Requested against applied, and the measured result (centroid measured 2026-09-13)
StepRequestedAppliedCentroid after
as writtenn/an/a1406 Hz
much darker-0.60-0.50 to -0.60149 Hz
much darker again-0.600 to -0.10141 Hz

The report does not say “done.” It says what it managed:

drop.drums.brightness 0 → 0    lpf 800 Hz            applied +0 of requested -0.60   saturated
drop.bass.brightness 0 → 0     lpf 80 Hz -> 80 Hz     applied +0 of requested -0.60   saturated
drop.melody.brightness .1 → 0  lpf 438 Hz -> 300 Hz   applied -0.1 of requested -0.60   saturated
drop.pad.register .3 → .1      octave 3

applied +0 of requested -0.60. Nothing happened, and the report says so.

It reports the delta you achieved, not the one you asked for. If the second identical command silently claimed success, the user would learn within two turns that the system’s reports are decoration, and after that they would stop reading them.

The measurement backs it up, because the rendered audio can be analysed: the spectral centroid falls from 1406 Hz to 149 Hz, then to 141 Hz. An 89 percent move, then a 5 percent one, from the same command.

Two parts had headroom in both steps, the piano and the pad. The pad was at 0.7, so it went to 0.1 and then to 0, and it is the place where the second command is most audible:

The one part that still had somewhere to go on the second pass.

Contradictions are summed, never refused#

Ask for two things that disagree and the system does not stop you. It adds the vectors and shows the working:

space: dreamier +0.35   punchier -0.15   net +0.20  (contradictory)
articulation: dreamier -0.20   punchier +0.40   net +0.20  (contradictory)
drive: dreamier -0.15   punchier +0.20   net +0.05  (contradictory)

“Brighter and darker” nets exactly zero on brightness and emits no edit for it. But it does not net to zero overall, because bright moves register by +0.05 and dark moves it by -0.10. The vectors are not opposites, they are just vectors, so cancellation happens per axis and a residue leaks through.

The language reads back, and it is lossy#

The same table runs backwards. The checker prints every part’s numbers as words, so you can ask what a section sounds like and get English, computed from the values that English put there. Round trip: word, number, word.

It does not survive the trip cleanly. Nineteen of the twenty-nine control words come back. None of the eight mood words ever do. And five of the twenty-four axis directions have no word at all:

Which axis directions have a word and which do notFive of the twenty-four directions cannot be named. Register is silent in both directions, so a change to it never appears in the read-back at all.

The mechanism is one owner per column. A word is a candidate for an axis only if that axis is the word’s own largest move, and then the largest of those candidates takes the column outright. spacious raises space by 0.35, but so does dreamy, and dreamy was defined first, so dreamy owns space and spacious can never be spoken. tight moves articulation by 0.25 and loses to punchy at 0.40. Ten words are mute this way, two of them on ties and eight because something else simply moves that axis harder. The four depth words are all among the mute: they were designed to move three axes gently, so none of them will ever own a column. Invisible to the read-back by construction.

hypnotic is the worst of it. It is a real word, it applies cleanly, it moves three axes, and the read-back for the result is nothing at all. Its largest move is on variation, and variation has no word in either direction.

The file after one “much darker” and the file after two have word-for-word identical descriptions, on all five parts, even though four of them changed. Every change in the second pass either landed on register, which is mute, or moved a value without moving it out of its word: 0.1 and 0 are both “very dark”.

A small discrete vocabulary projected onto a continuous space is going to lose things. The loss is measurable, though, which is why I can tell you it is 19 of 29 rather than “mostly fine.”

What the resolver refuses to touch#

Applying a word means editing source code, which is the part that can actually lose your work.

It parses the file with a real JavaScript parser, finds the numeric literals that are direct values of axis keys inside a section, and splices those byte ranges. Back to front, so earlier offsets stay valid. The parser is only ever used to locate; nothing is regenerated, so comments, formatting and key order survive untouched.

Then it refuses, and every refusal names the reason and the fix. Three that come up:

  • An expression instead of a number. If brightness is a signal sweeping over the section, it will not rewrite it: refused, brightness is a signal here; change its range by hand.
  • A key or progression that is not a plain string. The harmony half is refused.
  • An axis with no implementation on that kind of part. Skipped, and reported as skipped.

There is a fourth refusal that no pattern match would catch. A part built by spreading a shared definition ({ ...theme, density: .55 }) has its baseline hidden behind a value the parser would have to run the program to see. A delta needs a baseline, and guessing 0.5 would throw away whatever the shared definition contributed. So the library refuses to edit such a part unless the caller hands it the evaluated values. Both callers now do: the page has already evaluated the song in order to play it, and the command-line resolver evaluates it first for the same reason. What is left of the refusal is the case where no baseline can be had at all.

Harmony is deliberately not an axis#

Key and chord progression sit outside the whole system, and you can see it in the map of a song file: key and progression are on the section line, outside the box that holds the parts. An axis is a number from 0 to 1, per part, with a baseline at the midpoint. Harmony is none of those: there is no 0.5 of a chord progression, I IV V I does not interpolate toward anything, and a progression is shared by every part rather than owned by one.

So harmony words are states, not deltas, and the parser treats them differently on purpose: modifiers are discarded, negation does not apply, and applying the same harmony word twice is a no-op. Deltas are not idempotent; states are.

Writing the progression in numerals means chords are computed from the section’s own scale, so the same progression string produces correctly spelled chords in any key. What numerals do not give you is a shortcut past knowing what you asked for:

C:minor            i iv V i   →  Cm Fm Gm Cm
C:harmonic minor   i iv v i   →  Cm Fm G  Cm

Writing a bare V in capitals does not give you a major dominant. You get one when the key you chose has a raised seventh, and not before. The system refuses to infer that you meant harmonic minor, and makes you say so. (Write V7 and you do get the dominant seventh in any key, because a seventh has to commit to a quality.)

The grammar has grown past plain triads: @n holds a chord for n bars, /1 and /2 invert it and the printed name gains its slash bass (VI/1 in C minor is Ab/C), accidentals and m, M7 and dim suffixes do what they say, and a melody can be told follow: 'tones' so its written degrees walk each bar’s chord tones instead of the key’s scale. Voice leading is still not modelled, and the editor’s per-bar chord picker refuses a progression that uses @n, because one bar in a picker cannot stand for a hand-written repeat count.

Words that move#

A number is a position. Some words write a shape instead. Put a motion word next to an axis name and the resolver replaces the number with a movement centred on it:

wobbling brightness   .7 -> wobble(.5, .9)
brightness rising     .7 -> ramp(.7, 1)
swelling space        .5 -> swell(.3, .7)
The five shapes a motion word can write, over one four-bar sectionRising is a straight ramp across the section. Wobbling is one sine per bar. Swelling is one sine over the whole section, peaking at its midpoint. Pulsing sits high and dips for the second half of every beat. Drifting is seeded noise between the two bounds, the same every play.

Each movement already knows how long the section is, so a ramp spans exactly the section and a swell peaks in the middle of it. The rules are the same ones the deltas obey: the phrase’s own delta is applied first, so much darker, wobbling brightness wobbles around the darkened value rather than the original; a structural axis (density, drive, variation, register) refuses every shape, because it decides the grid before there is anything to sample; an axis that is already a signal refuses too; and two movements on one axis in one phrase is refused, one per axis.

Four bars each. The wobble is one sine per bar, so the filter opens and closes with every bar of the arpeggio.

Verbs#

The last kind of word is a macro. An arrangement verb is a phrase plus a material change plus a part cut, stored in JSON, and applied to everything inside one section( block at once: in the map of a song file, the middle box. There are four:

VerbWhat it is
breakdownthe phrase sparser, more spacious, darker, and drop the fx
liftthe phrase busier, brighter, more driving
stripkeep drums, bass, sample, perc and raw; cut the rest
halftimethe drum template becomes halftime, then slightly sparser

A verb runs through the same resolver, prints the same per-axis report, saturates in the same places, and lands on the same undo stack as a slider drag. A reader can check what breakdown does in ten seconds by opening the file.

Two bars each. Every part sparser, more spacious and darker in one step; the fx part removed.

Taste, as a linter#

The last layer up is a script that turns judgement calls into static analysis over the same table: there must be a climax, the climax should be the most energetic section, no two consecutive sections should be identical, two raw sawtooths in one section are mud, a bass or melody left on its generated default should be given a written line, and no section should ask the audio thread for more simultaneous voices than a laptop can render.

Errors and warnings are split on one line: errors are things that cannot be true (a value outside the range has no defined meaning), warnings are things that are usually not what you meant. Every musical judgement is a warning. Across the songs the page lists there are zero errors.

There are a couple of dozen warnings, though, and reading them is more instructive than the rules:

  • The largest piece, the one iterated on hardest, passes completely clean, and so do the four songs written most recently, so the rules are not just describing one song.
  • Fifteen of the twenty-nine warnings, counted on 2026-09-19 over the eleven listed songs, come from a single song that is violating the rules on purpose. It is an industrial track whose header comment says everything corrodes; colliding sawtooths are the genre, and a machine’s bass is a loop rather than a hook. Essentially every one of those warnings is wrong, and there is no way to suppress them.
  • One listed song is reported clean because it has no sections at all, so the linter has no opinion. Clean and silent look identical in the output.

Then there is the limit. Drive the climax to the state you heard at the top of this post, where every part has had its brightness pushed to the floor, and the linter’s verdict is unchanged. Its notion of energy counts events, not spectrum. You can turn a climax into inaudible mud and it will still certify that the arc peaks in the right place.

Taste encoded as static analysis can only see what the table records, and this table records rhythm. There is now a second linter that can see spectrum, because it runs over a rendered measurement rather than the table, and the mixing post is about what it sees.

The next post is about the place where all of this gets used with a mouse: the editor.