Local Strudel harness: edit songs/*.strudel, press play in a browser, hear it. All code and samples are served from this folder.
npm install
npm run samples # one-time, downloads Strudel's default packs (~300 MB, resumable, re-run if any fail)
npm start # http://localhost:3000
The same page is deployed to GitHub Pages by .github/workflows/pages.yml on every push to main (npm run pages builds it into dist/). There it has no server: save and new song keep songs in the browser's localStorage instead of songs/, sample packs stream from the Strudel CDN instead of samples/packs/, and of the local packs in samples/user/ only those whose pack.json says deploy ship with the site (songs that need the others are left out of the list there). Export works the same as locally (the browser renders and encodes), it just downloads instead of also writing renders/.
The page has two views: Compose (index.html) and Examples (examples.html, a scrollable showcase of playable snippets for every axis, descriptor, modifier, harmony word and section edit; entries marked "example coming" are placeholders). A third, local-only page, Samples (samples.html, linked from the nav once a local server answers), is where sample packs and their named definitions are made; see below.
- Pick a song, press ▶. ■ stops; pause remembers the cycle and resumes from it. Save writes the textarea back to the file. + New song writes a minimal
song()template tosongs/<name>.strudel. - The editable source and the expanded Strudel it reduces to sit side by side (stacked on narrow screens). The expanded pane follows the textarea as you type (expanded in the browser by
lib/dump.mjs, also on GitHub Pages); lines the last edit changed flash briefly, and the source pane says "unsaved" until you save. Switching or creating a song asks before discarding unsaved edits. - Change comments collects notes pinned at the cycle the playhead is on (pause where it bothers you, write what should change, Add note here) and builds one paste-ready request: song name, section map, the numbered notes with their bar, a line saying each note may be about that exact spot, its section, or what led into it, and the source of the sections around those spots (a first-bar note also quotes the section before). Why it sounds this way lists, per section, the
//comments in the source and the decisions recorded insongs/<name>.notes.json(the prompt the song came from, then one entry per request with the changes it made: section, layer, axis, from → to, why; the card shows the why with the request's date and ask as a tooltip).npm run note -- songs/x.strudel --ask "..." --change "drop.melody.brightness .5>.7 raised because ..."appends one;--prompt "..."sets the summary. On GitHub Pages the request card is hidden. The song pane'skitcontrol lists the drum kits the loaded packs actually have (a<kit>_bd/sd/hhprefix, local or CDN), each with a one-line label, hover description and icon fromlib/kits.json(icons are drawn from the description words bynode scripts/kiticons.mjs; the menu has a filter box and a play button per kit). Beside the machine kits there are recorded ones, each a user pack of<kit>_<voice>folders declared withpacks: [...]: twenty from tripjazz's Figure Drum Kits on Freesound (CC0, one pack per recording session so a bar never mixes two kits' takes:jazz,rock,studio,bern,hornstull,sonny,northeast,weekend,animal,elektro,boom,leeloo,mansion,rumble,urban,technologic,syntax,gosh,code,tr, their extras such as congas, bongos, tambourines and blips kept as sounds under the same prefix) andgarden, mot4i's re-amped one-shots and textures (free with credit).kit: 'jazz', packs: ['jazz']plays one. - The Mix card is the song as an instrument, and every control in it is a source edit (unsaved until Save, undoable with ctrl+z or the ↶ ↷ buttons, re-evaluated in place while playing). Top to bottom: a song pane (the header's tempo, key, seed, kit as inline controls), the arrangement strip (a block per section, one row per part shaded by density; click to scrub, 📌 to pin, ◀ ▶ to reorder, ⧉ to duplicate, ✎ or double-click to rename, × to remove, a ⚡ pick for the arrangement verbs (
breakdown,lift,strip,halftime), all shown on the selected block only; under every block four icon picks: the fade before the next section (dropout ┆ bars of silence, sweep ◣ a filter sweep) and the transition into it (riser ◢ bars, hit ★ a sample)), a section pane (that section's source with controls), the harmony strip (the key and one chord per bar, each a pick), the axes grid (a slider per axis the part adapts, with mute, solo and remove on each row and the part's events across the section's bars under it), materials (drum template and per-voice samples, each part's sound with a play button, notes with a roll under the box (click a degree per slot, shift-click to hold the note before), the melody's seed stepper showing the line it makes, arp, follow, level, and for a sample part the waveform with start/end sliders, a button per slice to hear it, a breaks box in seconds and a click on the waveform to add or remove a break, bars, slices, the play order and the fit toggle) and the phrase row (words become pills, only vocabulary words and part names are accepted, a part pill scopes the words after it; Apply rewrites the values, Verify renders before and after, measures both and shows the deltas next to two players; "all sections" widens either to the whole song). Pinning a section loops it on its own pattern, starts Play there and keeps the card on it; A/B in the card's header plays the song as it was before the last change; measure, next to it, renders the shown section and then each part alone and writes a dB readout next to every level slider (relative to the whole mix, a warning when the mix has no headroom or a part is silent), so a balance question is answered in numbers instead of by ear alone — it writes nothing; solo, mute and A/B change what you hear, never the source. Save also records the mixer's edits insongs/<name>.notes.json. Link in the export group copies a URL carrying the song itself, which opens in this page anywhere, unsaved. - Deep links:
#<song>.strudel/<section>opens the song on that section, as a click on its block would, and the address bar's hash follows the section the Mix card shows (a click, the playhead, a pin), so the URL you copy is the bars you are on. Embedding this page in another one:?play=1starts playing on load and?section=<name>opens pinned to that section, so an iframe can land on the exact bars the surrounding text is about (?play=1§ion=contact#arrival.strudel). Autoplay still needs user activation to reach the frame, which a reader's click on a click-to-load teaser provides; when the browser refuses anyway the transport just says Play and the button's tooltip says why, rather than looking like it is playing. - Editing a song file on disk reloads it in the page. If it was playing, it re-evaluates so you hear the change. If you have unsaved edits in the textarea, you get a reload link instead.
- The stage under the transport is the song's visual performance: a world composed from the song's structure at every play, never a generic visualiser. A world is one interpretation of the score: every world gets the same events as the parts sound (a kick, an impact, a hat, a bass note, a melody note, the pad, each tagged with its part and its role in the song) and the same clock (section, bar, beat, energy, risers, dropouts, boundaries, the climax) and turns them into its own motion, so one song looks different in each world and one world looks different for each song. The song header picks a world by name,
visual: 'sediment'(visual: { world: 'sediment', seed: 12 }gives the look its own seed apart from the generated lines'); unwritten, the song's features (its axes, tempo, form, whether it has a melody and pads) and its seed draw one from fuzzy odds, printed bynpm run checkon itsvisual:line. The worlds are whatever the stage's picker lists (they come and go;lib/visual.jsonis the data behind the odds and the cast); a song naming one that is no longer built plays the default world and the debug overlay says so. The mood, seed and mean axes set the look once, and the parts drive it as they sound, on the same clock as the ear, so what you see is what is playing, not a recording. The picker in the stage's top-left corner switches the world while the song plays (autois the song's own pick; a pick there is a view setting, never written to the song;[and]step through them), the button in the other corner fullscreens it;?vdebugin the address draws a diagnostic overlay. A song can also compose two worlds:visual: { composition: 'overlay', worlds: ['tunnel', 'ink'] }plays the first full and breathes the second in and out over it every eight bars (every: 32makes that eight-bar visit come once in thirty-two instead), up to a peak the section's energy sets, so a quiet section barely shows it and a loud one lets it take the frame. The worlds stay unaware: a director runs each on its own canvas and composites them, live and in the video export alike, and the picker lists the preset so any song can be tried composed. - Export Video makes the song's music video in the browser: the audio rendered offline (with three seconds of tail for the reverb), the stage's world run frame by frame at 1920×1080 and 30 fps from the same score and events the live stage plays (never a recording of the page), a title card from the song's name and its first comment line, encoded with WebCodecs:
<song>.mp4with H.264 and AAC where the browser has an AAC encoder, else H.264 and Opus (every Chromium encodes those), else<song>.webmwith VP9 and Opus; YouTube takes all three. In Chrome and Edge a save dialog opens first and the file is written to disk as it is made, so a long song is no bigger than the disk; elsewhere it is built in memory and downloaded.npm run video -- songs/x.strudeldoes the same through the open page, streamed intorenders/. - Export MP3 stops playback, renders the source offline in the browser, encodes it there and downloads
<song>.mp3. Export Strudel downloads the expanded pane as<song>.strudel.txt, ready to paste into the strudel.cc REPL, and Open opens it there. The badge next to the buttons shows the state (rendering with a percentage, ✓ file, ✗ why) and the buttons lock while a render runs. All work identically on localhost and GitHub Pages; with the local server the file is also written torenders/(the ✓ tooltip says so). - Your own samples live in local packs: one folder per pack,
samples/user/<pack>/. Inside it<sound>/*.wavis a sound with variants (s("<sound>:2")picks the third file) and a loose audio file is a single sound named after the file. A song declares the packs it uses,song({ ..., packs: ['<pack>'] })(a comment linepacks: ['<pack>']does the same in a plain Strudel file), andnpm run checkrefuses a pack sound the song does not declare, a declared pack that is not insamples/user/, and any sound in no pack at all. Nothing is ever substituted: a missing pack plays silence for its sounds, and the page says so. The header shows the song's packs as deployed, local or missing; the status line lists every local pack this environment has. Adding a pack touches no code: a folder, and apack.jsononly if it should ship.samples/user/demo-pack(a generated sine blip, CC0) andsongs/ping.strudelare the worked example. The Compose page imports a file for a sample part (materials → import…, or drop it on the row): it lands insamples/user/<song>/, the song declares the pack, and a license at the prompt writes thepack.jsonthat ships it;songs/chop.strudelis the worked example, on the demo pack's generated loop. - Deploying local packs (GitHub Pages) is opt-in per pack through
samples/user/<pack>/pack.json:{ "deploy": true, "license": "CC0-1.0", "source": "..." }ships the whole pack,"deploy": ["kick", "snare"]ships only those sounds, no file (or"deploy": false) keeps it local-only. The policy covers the folder, not a file list: a file dropped into a deploying pack ships under that pack's licence (the Samples page says so on the drop target).npm run pagescopies only what ships, refuses to deploy a pack with nolicense, leaves songs that declare a non-shipping pack out of the deployed song list (their files do not ship either), and re-checks every deployed song against exactly the shipped sounds, so a subset that drops a sound a song uses fails the build instead of playing silence online. Built-in packs are unaffected: they come fromsamples/packs/locally and stream from the Strudel CDN on Pages. - Named samples are a pack's own
pack.jsongaining asamplesobject:{ "loop": { "bars": 2, "slices": 8 }, "loop-kick": { "sound": "loop", "end": .0625, "bars": .125 } }(sounddefaults to the definition's own name; one file can back several named regions this way). A song plays one by name,sample: { sound: 'loop-kick' }, and can override any of its keys on the part same as always; the song still has to declare the pack. Since the definition lives in the pack, any song that declares it can use it without repeating the region. Defining them is a dev-only page,samples.html(not deployed, not on the song dropdown): a waveform per definition, with a tempo guess from the region's onsets (click to set that definition'sbars; on the Compose row instead, the same guess offers to set the part'sbarsor the song'sbpm), drag to move a break or the region's edges, and a snap select (off, 1/4, 1/8, 1/16, remembered per browser) so a drag lands on the beat grid; the same waveform component gives Compose's own row this drag and snap too.
npm run check -- songs/x.strudelevaluates a song headlessly, prints the first 4 cycles of events (plus the per-section axis table with each layer's values read back as vocabulary words, key and chord names, anarc:line of each section's energy and avisual:line, the composition a renderer would draw, forsong()files), and fails on syntax errors or sound names that are not in the local packs, the user folder, or the built-in synths. No args checks every song.npm run lint -- songs/x.strudelapplies the musical rules to that table: a climax exists and is the most energetic section, no two consecutive sections are identical, no section has two raw saws, every melody and bass writes its notes, axes stay in 0..1 and level in 0..2. Warnings for taste, errors for the impossible (exit 1). No args lints every song.npm run dump -- songs/x.strudelprints the plain Strudel asong()/section()file reduces to. Read-only.npm run form -- --mood=ominous --bars=64 --seed=5 > songs/new.strudelgenerates a song skeleton: six sections with roles and bar counts, key, tempo and progressions chosen by the mood, axis values pushed by the mood's overlay vector. Then material first (see the skill).node gen/euclid.mjs --seed=3is the older, plain-Strudel generator. Generators are plain scripts that print code to stdout.npm run snippets -- clips.json [--out-dir DIR] [--force]renders a batch of short clips as small mono MP3s, one per entry in the manifest ({ out, song, section?, layer?, cycles?, kbps?, mono? }, the same arguments asrender). Defaults are 64 kbps mono, roughly a third the size of a full-quality render, which is what a few bars embedded in a web page wants. The page has to be open, so startnpm run headlessfirst. A clip that already exists is skipped, so re-running fills only the gaps.npm run headless -- songs/x.strudelopens the page in a headless Chromium sorenderandverifyrun with no window (starts the server if none answers; needsplaywright-core, installed as a dev dependency, plus a browser:npx playwright-core install chromium, orCHROME=<path to a chrome binary>).npm testruns the node:test suite.test/golden.test.mjspins the events of the fixture songs intest/fixtures/(copies kept still, so a change there is a change inlib/); the songs insongs/only have to check clean.node scripts/samplemeta.mjs [pack ...]measures every wav of every pack into<pack>.meta.json(samples/user/<pack>/meta.jsonfor a user pack): seconds, rms, peak, rate, bits, and what the hit is made of, fromlib/hitwords.mjs: the drum voice its name points at, attack and decay in ms, spectral centroid, the share of energy under 150 Hz and over 5 kHz, flatness, and those as words, "tight deep snap, all low, dull, tonal" (decay tight/medium/long, tone deep/mid/bright with edges per voice, attack click/snap/soft, weight all low/heavy/some low/no low, air airy/some air/dull, texture noisy/mixed/tonal). The check prints the words next to each sound, and the local-only catalogue page (catalog.html, reachable from the header when a server is behind the page) lists every sound of every pack with them, filtered by source, decay, tone, attack, weight, air, texture, length and licence, every word a clickable tag: a kit or a take is picked by reading its row before anyone listens.node scripts/hitfeatures.mjs --dir <folder>measures any folder of one-shots the same way before it becomes a pack.
server.mjs stdlib http server: static files, song api, sse reload, render/dump routes, user sample map
index.html Compose: song header, transport, source + expanded strudel, section feedback, offline renderer
examples.html Examples: data-driven playable cards (GROUPS at the top of its script), stubs where content is pending
about.html About: what strudel-bench is and why; legal.html: privacy and disclaimers (static, no strudel loaded)
samples.html Samples: local-only workshop for pack management and named sample definitions; not deployed, not on the song dropdown
404.html not-found page, served by the local server and by GitHub Pages for any unknown path
web/ boot.mjs (shared initStrudel/prebake, playCode, nav, footer), compose.mjs (kit + notes + request helpers),
examples.mjs (the GROUPS data), mp3.mjs (lamejs wrapper), sampler.mjs (waveform peaks, time labels, tempo detection, snapping),
waveform.mjs (the waveform component: draw, drag, snap; shared by Compose and Samples), workshop.mjs (Samples page's pack.json helpers),
stepgrid.mjs (the step grid editor: voices x steps, click to cycle rest/hit/accent/ghost), lane.mjs (a signal axis's automation lane, drawn across its section),
kits/ (one icon per kit), strudel.css, icon.svg, og.png
songs/ one .strudel file per song
lib/ the axis system (see below) plus data files patches.json (named voice-control bundles) and motion.json (motion words), loaded by both the page and the Node scripts
samples/packs/ downloaded packs (gitignored) + <pack>.json maps + packs.json
samples/user/ your sample packs, one folder each; pack.json = deploy policy (see Use)
renders/ wav/mp3 renders and dumps (gitignored)
scripts/samples.mjs pack downloader
scripts/check.mjs headless checker (events, axis table with words, harmony, energy arc)
scripts/lint.mjs musical lint over the check table
scripts/headless.mjs the page in a headless Chromium, for render/verify with no window
scripts/dump.mjs song() -> plain Strudel
lib/resolve.mjs words -> axis edits (scripts/resolve.mjs is the cli; the Compose page Mix card uses it too)
scripts/render.mjs drive the page's offline renderer
scripts/mp3.mjs wav -> mp3 (lamejs), --kbps and --mono for small web clips
scripts/snippets.mjs a manifest of short clips -> small mono mp3s, for embedding in a page
lib/analyze.mjs wav metrics and deltas, no dependencies (scripts/analyze.mjs: cli and wav writing)
scripts/verify.mjs render -> resolve -> check -> render -> analyze -> report
scripts/vocab.mjs generate the skill's vocabulary reference from code
scripts/note.mjs record why a change was made into songs/<song>.notes.json
scripts/kiticons.mjs redraw web/kits/*.svg from each kit's description in lib/kits.json
scripts/samplemeta.mjs measure every pack file (seconds, rms, peak, rate, bits, the hit words from lib/hitwords.mjs) into its meta.json
scripts/freesound.mjs a folder of Freesound downloads -> a licence-checked, peak-normalised kit pack
catalog.html Catalogue: local-only, every sound of every pack with its words and filters; not deployed
scripts/esm-fix.mjs node resolve hook (a strudel dependency ships without an exports map)
gen/ generators: form.mjs (a song skeleton from a mood), euclid.mjs (plain Strudel)
test/ node:test suite; test/fixtures/ are the songs the golden test pins
.claude/skills/strudel/ the song-editing skill (the one versioned path under .claude/)
blog-ideas/ notes on making songs with an agent
GM soundfont instruments (gm_*) still stream from GitHub when used. Built-in synths and every downloaded pack are local.
A sister repo, the sound store (strudle-pack, cloned next to this one at E:\github2\strudle-pack), sells real recordings: Nine Inch Nails' The Slip and Ghosts I-IV multitracks, CC BY-NC-SA 3.0, cut into bar-exact loops (l1/l2/l4), hits and transcribed sung phrases. Each clip carries this repo's own axis words (feel=dark, moods=ominous), bpm, section, loudness and, for a vocal, its transcript and a rough pitch. Its find searches the catalog and its export copies a few clips into samples/user/<pack>/ as an ordinary local pack.
Use it sparingly. It is a spice rack, not a pantry. A song here is written from this repo's own layers and packs, and a borrowed clip is added late, to fill one named gap the built-in sounds cannot. The first try (2026-09-21, since deleted) went the other way: it chose the tempo and key to suit the clips and stitched a lyric from lines of five songs. Every clip was easy to find and fitted on paper, and the song did not work. Finding sounds is easy. Building a song around them is hard.
What a borrowed clip is for, most useful first:
- Atmosphere. The store's best stock is ambient: held strings, drones, delay and echo textures, found sounds, mostly from Ghosts. A borrowed bed or texture under written parts is the use that works best (
songs/black-ice.strudel). - A human voice, rarely. A breath, an "ah", a wordless phoneme, used as an instrument every eight or sixteen bars. It always sounds with other parts under it and is never the main attraction. A sung line with words is the exception, not the rule. Two tries that put vocals up front (2026-09-21) failed.
- A played riff. A real guitar through a real amp, or a bass line with a player's feel, as one part among the written ones.
- Grit under a kit. A recorded drum loop low under the machine kit, for the room and real cymbals.
- A one-off texture. A hit or noise the built-in packs lack.
What it is not for: the song's harmony, its whole kit, its lyric, or its form. The test is to mute every borrowed part: the song must still stand on its written groove, bass and motif. If it does not, it is being built around the clips.
How to fit one:
- The song comes first. Tempo, key, sections and written parts exist and
npm run checkis clean before shopping. Then name the gap in one sentence, such as "the chorus needs a shouted word on the fourth beat". - Tempo binds loops, not phrases. A loop plays only at its source song's bpm or a clean ratio of it; anything else resamples it and shifts its pitch. Filter by
bpm=. Hits and sung phrases have no tempo and fit any song. A loop'sbarscounts its source song's bars: Demon Seed and Ghosts 38 have six beats to the bar, so theirl2lasts three 4/4 bars. Readbeats_per_barin the pack'smanifest.jsonand writebarson the sample part. - Pitch binds phrases. A sung phrase carries its notes. Filter
pitchHznear the song's key, prefer a held note (pitchRange="<3") or a spoken or shouted one, and move it withtransposeon a sample part or.speed()on a raw one. A semitone is also a 6% length change. The catalog's pitch is a median estimate, so treat it as a hint. - A riff brings its chords. Put a borrowed riff where the section holds its root (
progression: 'i'), never under moving chords. - Words carry meaning, and borrowed words carry someone else's. One line repeated reads as intent. Lines from several songs read as a collage. Prefer one word or one line as a motif, placed and repeated, or chop it into an instrument with a sample part (
slicesand apattern), where the syllables stop being a sentence. A song that needs a lyric needs one written for it. - It sounds like another record. A stem carries its own room, mic and compression, and it sits on top of a synthetic mix. Run the mixing pass on it: level first, then brightness and space.
In the song, a phrase or hit is a raw part, one per bar with <>: raw: { pattern: s("<~ name ~ ~>"), level: 1.2 }. A loop is a sample part, sample: { sound: 'name' }, since the exported pack.json gives it its bars.
Housekeeping. Export only the clips the song plays, one to three, into samples/user/<song>-cuts/, and declare the pack in the song header. Commit those few files together with the LICENSE-ASSETS.md the export writes, because a song in songs/ must check clean on a fresh clone. Never export a bank. The license is CC BY-NC-SA 3.0 with attribution to Nine Inch Nails, so a song using a clip is a non-commercial, share-alike derivative. The export writes deploy: false. Pass --deploy only if the song should play on GitHub Pages, which is non-commercial and keeps the attribution in the folder. Record which clip was borrowed and why with npm run note.
Songs can be written as sections × layers × axis values (see songs/demo.strudel):
song({ cps: .5, key: 'C:minor', seed: 1, kit: 'RolandTR909' }, [
section('drop', 8, { role: 'climax', drums: { density: .7, drive: .8 }, bass: { weight: .8 }, melody: {}, pad: { space: .7 } }),
])
Eight layers — drums, bass, melody, pad, fx, sample, perc, raw — each take axis values in 0..1 where 0.5 is that layer's baseline. Then:
npm run resolve -- songs/demo.strudel drop drums "punchier" # what would change
npm run resolve -- songs/demo.strudel drop drums "punchier" --write # do it
npm run render -- songs/demo.strudel --section drop # renders/demo.drop.wav (page must be open; it stops playback first)
npm run render -- songs/demo.strudel --mp3 # same, then renders/demo.mp3 (or the page's "export mp3" button)
npm run mp3 -- renders/demo.wav # convert an existing render
npm run video -- songs/demo.strudel # renders/demo.mp4 (or .webm): the song's music video, made by the open page
node scripts/analyze.mjs renders/a.wav renders/b.wav # metrics and deltas
npm run verify -- songs/demo.strudel drop drums "punchier" # the whole chain with a report
npm run vocab # regenerate the skill's vocabulary reference
Vocabulary is data: lib/descriptors.json (control words → axis deltas), lib/overlays.json (emotions and genres),
lib/harmony.json (progression and mode words). Modifiers like slightly, much, extremely scale a delta.
The strudel skill (.claude/skills/strudel/SKILL.md, versioned: the rest of .claude/ is gitignored)
encodes the baseline → resolve → check → render/verify → report workflow for an agent editing songs by ear.
blog-ideas/making-machine-with-claude.md is a worked example of that loop from the user's side.
- A primitive axis exists only if it has a deterministic, layer-aware implementation; everything else is a descriptor or an overlay.
- Axes are semantic, adapters are mechanical — an empty cell honestly says "no implementation on this layer".
- Descriptors are deltas, not states: dreamier applies relative to the current values.
- Harmony is a separate subsystem, not an axis.
- No new trajectory concept: an axis value is a constant in 0..1, a Strudel signal, or
ramp(a, b). - Adapter(0.5) is a no-op — literally the material's baseline, verified by test; a direction with no honest implementation is a documented no-op, not a guess.
- Adapters run in fixed phases, because transformations do not commute.
- The resolver edits declarative state, never hand-authored Strudel expressions.
Kind, meaning and verification class are the ones declared in lib/axes.mjs (AXES).
| Axis | Kind | Meaning | Verification |
|---|---|---|---|
| density | structural | amount of musical activity | direct: onsetsPerSec |
| drive | structural | rhythmic insistence toward the primary pulse: where onsets fall and which are accented, not how many | code |
| brightness | continuous | spectral character, dark to bright | direct: centroidHz |
| weight | continuous | perceived low end and body | direct: lowRatio |
| space | continuous | dry and close to spacious | proxy: tail |
| articulation | continuous | sustained and smooth to short and punchy | proxy: crest |
| aggression | continuous | smooth to abrasive | proxy: flatness |
| groove | continuous | rigid to swung | proxy: swing |
| variation | structural | repetitive to variable (0 = pure loop) | proxy: novelty |
| organicness | continuous | mechanical to humanized | proxy: jitter |
| width | continuous | narrow to wide | direct: width |
| register | structural | low to high | code |
Phase order (PHASES): structural → timing → pitch → articulation → spectral → spatial → level.
Rule 4 stands: harmony is material on the section, not an axis. Two reserved section keys next to role:
section('drop', 8, { role: 'climax', key: 'Eb:major', progression: 'I V vi IV', drums: {...}, bass: {...} })
keyoverrides the song key for that section (any Strudel scale name, soC:harmonic minorgives a real V in minor).progressionis roman numeralsI..VII, any case, one chord per cycle (or several in[..]), looping. Degrees are diatonic to the section key;npm run checkprints the chords you actually got (Iin C minor printsCm). Default isi VI.- Pad voices the chord, bass transposes its line by the chord root, melody stays in key.
- Resolve phrases accept harmony words as states: progressions (
resolved,tense,pop,epic,circular,static,unresolved), modes (major,minor,dorian,lydian,mixolydian,phrygian) andrelative.npm run resolve -- songs/x.strudel drop '*' "relative major, pop"writes both fields. A progression word spells itself for the section key's mode (lib/harmony.jsonholds one spelling per mode family plus an override where a mode's own diminished degree would get in the way), sotensein a major key isI bVII bVI bVII, not avii°by accident, and the word means the same thing wherever it is used. @nafter a numeral holds that chord fornbars (i@2 VIis three bars, the tonic on the first two),nfrom 1 to 64;/1//2inverts it, rotating the lowest tone up an octave (the name gains a slash:VI/1is Ab/C). Melodyfollow: 'tones'maps the written line's degrees onto the chord's own tones (0 root, 1 third, 2 fifth, 3 seventh, 4 the root above) instead of the key's, so the same degrees harmonize as the chord changes.@nis written by hand, not by the Mix card: the harmony row's per-bar chord picker refuses to edit a progression that uses it, since one bar in the picker cannot stand for a hand-written repeat count.- Not modeled: voice leading, chord symbols.
Material is a literal value on a layer or a section, never an axis (rule 4). Omit it and nothing changes.
Parts render on superdough orbits (.orbit(n)), where reverb and delay live. Parts whose reverb would be identical share one orbit, one convolver (orbitKey in lib/song.mjs): under a room that sets size, every part (a room without one still splits by each part's own size); bass and melody at any space, signal or number (their cells only set the send, never a size, so they share superdough's default reverb); otherwise the same layer kind at the same space value. A ducked part, a raw part, and a space signal on a part whose cell sets a size get a private orbit. The rule is keyed by layer kind, not read off the built haps, so a new cell that sets size or a delay time must be added to it. Sends stay per hap, so parts on one bus keep their own amounts. Numbers run in order of first appearance in the section.
sound may be a list (['a', 'b']: a pick per hit, deterministic), weights ({ a: 3, b: 1 }) or a double-quoted "<a b>" (per bar); drum voices in sounds too. On a sample part a list is a list of definitions sharing bars and slice count, each keeping its own region.
Rhythm has one written grammar everywhere it appears: x hit, X accent, o ghost, . rest, | between bars, or p/s for p euclidean hits over s slots (p/s+r rotates). Drums take one grid per voice as template: { bd: '...', rd: '...' } (any voice name — the five kit voices stay density-gated, an extra voice always plays), bass takes the same grid as rhythm, and perc is one bare sound (no kit) on a rhythm. raw skips the grid entirely: a plain Strudel pattern is the part's material.
| Where | Key | Value | Effect |
|---|---|---|---|
| bass, melody, pad, fx | sound |
any local synth or sample name | replaces the default (sawtooth; white noise for fx). Drums take sounds instead |
| any layer | level |
number, 1 = untouched | gain multiplier, applied after every axis |
| any layer | postgain |
number 0..2, 1 = untouched | superdough's last gain, after distort/shape (whose output is tanh clamped to full scale whatever the gain before them): the only level a distorted part answers to, so a saturated kit or bass comes down with it, never with level |
| any layer | duck |
another part's name in the section | that part's hits sidechain this part's bus (superdough's own duck), dipping and recovering it |
| any layer | duckDepth |
number 0..1, default .5 | how deep the duck goes; only meaningful with duck set |
| any layer | duckAttack |
seconds, default .1 | how long the duck takes to recover; only meaningful with duck set |
| any layer | position |
-1 (left) .. 1 (right), 0 centre | where the part sits: shifts every hap's pan by position/2, so width moves around it and a drum kit's voice spread moves as one |
| any layer | velocity |
a mini string of multipliers, '.8 1 .9 1' |
superdough's velocity per step over one bar ('<[...] [...]>' for two), under gain and the grid's accents |
| any layer | humanize |
{ timingMs, velocity, length, correlation } |
a seeded played feel that is correlated, not dice per hit: three curves of time (a slow wave over correlation bars: 'bar', 'phrase' = 4, or a count; a fixed lean per beat position; a little residual) move timing (0..timingMs late: never early, since a hit moved before a render's start would be dropped), gain (± fraction) and note length (± fraction, only where the part has a clip; a sample without one plays to its end) together, the same way every play |
| any layer | compressor |
{ threshold (dBFS), ratio, knee, attack, release } |
the part's own DynamicsCompressorNode; only threshold is required |
| song, section | room |
{ size, fade, damping, dimension, ir, irbegin } |
one reverb character for every part (superdough's roomsize/roomfade/roomlp/roomdim/ir/irbegin on every orbit); a part's space stays its send, its size is overridden; section overrides song. size is the decay in seconds and truncates an ir; fade ramps the reverb's own head up from zero (softens an attack, not a true pre-delay); an ir with a silent head is a real pre-delay and irbegin (0..1) reads it from a fraction in |
| drums | template |
'house' (default), 'breaks', 'minimal', 'halftime', or { voice: grid } |
the base grid the axes thin out, place or fill; a written grid may name any voice |
| drums | sounds |
{ sd: 'rim', hh: 'hh:2' } |
per-voice sound; a name the kit has keeps the kit (the 909 rim), any other sample plays as named (cajon) |
| bass, melody, pad | patch |
a name (pluck, reese, hollow, glass, breath, wide, sub, driven (a ladder filter with drive), warm (the same, softer, with its own lpf 6000), lib/patches.json; keys include ftype (12db/24db/ladder), drive (0..3) and lpf; a ladder is a worklet per hit; the sub patch, a plain vibrato-free voice, and the sub material, a sine under the notes, are different things) or an inline object of the same controls |
a bundle of voice controls (filter/pitch envelopes, FM, noise, unison) applied under the axes |
| bass, melody | notes |
mini-notation of scale degrees | replaces the seeded line; density thins or doubles it instead of choosing one |
| bass | rhythm |
grid string | replaces the density-placed grid with a written one; notes still gives the pitches |
| pad | chord |
a scale degree or mini-notation of degrees | pins the pad to that degree instead of the progression; the bass still follows it |
| drums | fill |
true/false or a bar count n (>= 2) |
snare roll in the last half bar of the section, or of every nth bar; on by default before a climax section; the integer form replaces that automatic end-of-section roll, it does not add to it |
| pad | arp |
'up', 'down', 'updown' or "0 2 1 2" |
arpeggiates the chord in 8ths (8 notes a bar in 4/4, 6 in 3/4) |
| melody | follow |
true or 'tones' |
true moves the line with the chord root; 'tones' maps its degrees onto the chord's own tones instead |
| melody | phrase |
integer bars | the seeded line spans that many bars |
| fx | riser |
true (4 bars) or bars |
noise sweep into the next section |
| fx | impact |
true (bd) or a sound |
one hit on the downbeat, half speed, big room |
| sample | sound |
any loaded sample, or a pack's named sample definition | the file the part slices (npm run check prints the file it resolves to); a definition's other keys sit under the part's own, which win |
| sample | begin, end |
fractions of the file, 0..1 | the region used (default the whole file); the trim is folded into the slice grid |
| sample | bars |
number | what the region stands for at the section tempo (default 1): playback speed follows |
| sample | slices |
integer, or a list of break points (fractions of the file inside the region) | equal slices of the region, or slices at exactly those points: [.06, .125, .5] |
| sample | pattern |
mini-notation of slice indices | the order, spanning the sample's bars: unwritten default is every slice in order ('0 1 2 3 4 5 6 7', the loop as recorded), '0 1 [2 3] 0' a chop |
| sample | stretch |
true |
each slice fitted to its step, what Strudel's fit() does, from the section tempo; off, a slice keeps its own length at the fitted speed |
| sample | transpose |
semitones, -24..24 | scales the playback speed by 2**(transpose/12), so pitch and length move together (no pitch-preserving stretch) |
| perc | sound |
any local synth or sample name (no kit) | the one sound the part plays |
| perc | rhythm |
grid string | the written rhythm (default an even 16ths grid) |
| raw | pattern |
a plain Strudel pattern | the part's material, in place of any layer-built pattern |
| song, section | kit |
drum machine name | section overrides song |
| song, section | meter |
'4/4', '3/4', '6/8', '7/8', '5/4' |
one bar is still one cycle; 16th grid |
| song | bpm |
number | instead of cps: beats per minute on the meter's denominator |
| section | bpm or cps |
number | that section plays at its own tempo |
| section | dropout |
bars n |
silences every part but fx for the last n bars (a note already sounding at the boundary rings out: the mask drops new onsets, not held notes) |
| section | sweep |
bars n |
over every part but fx during the last n bars, opens the filter to 8 kHz at the start of the tail and closes it to 150 Hz by the end, overriding that part's own brightness for those bars |
| any part | enter |
bars n |
the part is silent for the section's first n bars (a late hat, a pad that joins) |
| bass | sub |
0..1 | a sine an octave under every note at that gain, stripped of the voice's filter and drive: the fundamental a driven bass loses (not the sub patch, a plain vibrato-free voice: a different thing) |
| any part | exit |
bars n |
the part is silent for the section's last n bars, counted from the end like dropout (a note already sounding at the boundary rings out: the mask drops new onsets, not held notes) |
A key that is neither an axis nor that layer's material throws, naming both lists: a typo (arpp: 'up') is a build error, not a silent no-op. The same goes for song metadata (bmp: 120 throws).
ramp(a, b) is an axis value that sweeps over exactly the section: brightness: ramp(.3, .8). wobble(a, b, bars), drift(a, b), pulse(a, b, per) and swell(a, b) are named movements next to it: a sine, a perlin wander, a square dipping every per beats a bar, and a sine centred in the section, each already knowing the section's length. A motion word next to an axis name in a resolve phrase writes one of these around the current value: "wobbling brightness" writes wobble(v-.2, v+.2), "brightness rising" writes ramp(v, v+.3); structural axes and existing signals refuse, same as every other signal edit.
Sections are JavaScript, so reuse them with spread: const verse = { drums: {...}, bass: {...} }; section('verse2', 8, { ...verse, drums: { ...verse.drums, variation: .5 } }). The resolver reads literal values only, so it refuses a layer built with spread (it cannot see the baseline it would be editing) — set those axes by hand.
Progressions accept b/# before a numeral, m/M/dim and 7/M7 after it, [..] to put chords in one bar, @n to hold a chord for n bars, and /1//2 to invert it: 'i bVI [III VII] V7 i@2 VI/1'. Case never changes a plain diatonic triad's quality (write IVm for a borrowed iv), but it does set the triad under a seventh: V7 is the dominant seventh in any key (G7 in C minor), v7 the diatonic one (Gm7), VM7 a major seventh (Gmaj7), and a lowercase diatonic seventh keeps its quality (vii7 in C major is Bm7b5). npm run check prints the chord names you actually got.
The full design spec and plan live in docs/superpowers/, which is not versioned in this repo.
MIT, see LICENSE, for the code and the author's own songs. Sample packs keep their own licenses: the Strudel CDN packs as published upstream, local packs whatever their pack.json declares. A sound-store pack is CC BY-NC-SA 3.0 attributed to Nine Inch Nails, with LICENSE-ASSETS.md and per-clip manifest.json in the folder and both shipped beside the audio; ShareAlike is viral, so a song declaring one of those packs is itself CC BY-NC-SA 3.0 and non-commercial, not MIT.