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.

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:
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.
| Table | Words | Non-zero cells | Possible cells |
|---|---|---|---|
| control words | 29 | 73 | 348 |
| mood words | 8 | 30 | 96 |
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#
| Step | Requested | Applied | Centroid after |
|---|---|---|---|
| as written | n/a | n/a | 1406 Hz |
| much darker | -0.60 | -0.50 to -0.60 | 149 Hz |
| much darker again | -0.60 | 0 to -0.10 | 141 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:
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)
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:
| Verb | What it is |
|---|---|
breakdown | the phrase sparser, more spacious, darker, and drop the fx |
lift | the phrase busier, brighter, more driving |
strip | keep drums, bass, sample, perc and raw; cut the rest |
halftime | the 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.