@vibes.diy/prompts 14.3.82 → 14.3.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,686 @@
1
+ # Strudel — live-coding music from text patterns
2
+
3
+ When someone asks for live coding, Strudel or Tidal, mini-notation, an
4
+ algorave page, "type a beat and hear it", a pattern jam, generative or
5
+ algorithmic music built from little text patterns, a groovebox whose buttons
6
+ write a pattern, or a page where a model rewrites the music on request, this
7
+ is the recipe. Strudel is a music engine that plays **patterns written as
8
+ text**: `s("bd*4, ~ cp ~ cp, hh*8")` is a whole groove, and a pattern can be
9
+ typed by a person, written by an interface, saved as a document, remixed, or
10
+ produced by `callAI` from a sentence. The engine arrives from the CDN the
11
+ first time someone presses play, so the app ships only its own code.
12
+
13
+ The sibling boundary: a synth built from oscillators, a drum machine with its
14
+ own clock, a Tone.js instrument, mic monitoring, a metronome — those are the
15
+ `web-audio` skill. Strudel is the right call when the music is a pattern
16
+ expressed as text (or compiled into text from a grid, sliders or a prompt).
17
+
18
+ The worked examples are `jchris/strudel` (an editor, saved patterns and a
19
+ prompt box that rewrites the groove), `jchris/strudel-groovebook` (a step
20
+ grid whose toggles write mini-notation, with a shared public gallery), and
21
+ the four tutorial vibes `jchris/strudel-hello-beat`, `strudel-notation`,
22
+ `strudel-layers` and `strudel-effects` behind the "You can just make music"
23
+ posts on good.vibes.diy.
24
+
25
+ ## The engine arrives at play time
26
+
27
+ **Import the engine with its full pinned esm.sh URL inside a memoized
28
+ module-level loader, and call that loader from the play gesture.** The
29
+ import is a body-position `await import("https://esm.sh/@strudel/web@1.3.0")`:
30
+ the platform rewrites bare specifiers only in the top-of-file import region,
31
+ so a lazy import carries the complete versioned URL. Laziness is the point —
32
+ first paint owes nothing to a 165 KB engine plus a drum kit, and the
33
+ AudioContext wants a user gesture anyway, which the play button is.
34
+ **The loader makes the `AudioContext` itself, synchronously, before its
35
+ first `await`**, resumes it, and hands it to the engine with
36
+ `setAudioContext` ahead of `initStrudel`. The engine's own first-click
37
+ unlock is registered only once the download finishes, by which time the
38
+ press that started it has passed; a context made and resumed inside that
39
+ press is what iOS asks of sound (measured 2026-10-07: the handed-in context
40
+ is the one the engine plays through, `running`, events firing after a
41
+ 1.7 s load).
42
+
43
+ Strudel is AGPL-licensed. The app links to the engine at its own address the
44
+ way a browser links to any page, so the two programs stay separate:
45
+ **credit Strudel visibly** (a footer line with a link to strudel.cc is the
46
+ house shape), and keep its code at its URL rather than copied into the app.
47
+
48
+ The drum kit is the Dirt-Samples bank, loaded in `prebake` from the jsDelivr
49
+ mirror (`https://cdn.jsdelivr.net/gh/tidalcycles/Dirt-Samples@master/strudel.json`);
50
+ jsDelivr serves it with CORS and without rate limits. The first play fetches
51
+ the engine and the kit together (measured 2026-10-07: about two seconds in
52
+ Chromium), so the play button shows a short "fetching the drum kit" state on
53
+ that first press and plays at once ever after.
54
+
55
+ **`evaluate()` reports a pattern that did not compile through `onEvalError`
56
+ rather than by throwing** (measured: a syntax error logs to the console, the
57
+ call resolves with nothing, and the previous pattern keeps playing). Pass
58
+ `onEvalError` to `initStrudel` and show its message in the app's own words,
59
+ beside the editor, while the last good pattern plays on.
60
+
61
+ ## Project shape
62
+
63
+ ```
64
+ App.jsx the interface: editor or grid, play and stop, saved patterns, the credit line
65
+ lib/strudel.js the loader, play(), stop(), the error hook, helpers that write mini-notation, EXAMPLES
66
+ access.js who may publish a pattern to the shared gallery
67
+ seed.json three or four finished patterns so the first visit has something to press
68
+ ```
69
+
70
+ Relative imports include the extension (`./lib/strudel.js`). Everything that
71
+ touches the engine lives in the module, so `App.jsx` reads as an interface.
72
+
73
+ ## Complete starter — a pattern pad
74
+
75
+ The engine module. `play(code, onEvent)` compiles a pattern and starts it;
76
+ the optional `onEvent` callback receives every sounding event so the
77
+ interface can light up with the music; `stop()` hushes; `onPatternError`
78
+ delivers the message a bad pattern produces.
79
+
80
+ ### `lib/strudel.js`
81
+ ```js
82
+ const ENGINE_URL = "https://esm.sh/@strudel/web@1.3.0";
83
+ const DRUM_KIT_URL = "https://cdn.jsdelivr.net/gh/tidalcycles/Dirt-Samples@master/strudel.json";
84
+ const DRUM_MACHINES_URL = "https://cdn.jsdelivr.net/gh/felixroos/dough-samples@main/tidal-drum-machines.json";
85
+
86
+ let enginePromise;
87
+ let audioContext;
88
+ const listeners = new Set();
89
+
90
+ /** One fetch of the engine and the drum kit, however many times play is pressed. */
91
+ export function loadStrudel() {
92
+ enginePromise ??= (async () => {
93
+ // Runs inside the press that called play(): the context is made and resumed in the gesture.
94
+ audioContext = new AudioContext();
95
+ audioContext.resume();
96
+ const web = await import(ENGINE_URL);
97
+ web.setAudioContext(audioContext);
98
+ const repl = await web.initStrudel({
99
+ prebake: () => Promise.all([web.samples(DRUM_KIT_URL), web.samples(DRUM_MACHINES_URL)]),
100
+ onEvalError: (error) => {
101
+ for (const fn of listeners) fn(error);
102
+ },
103
+ });
104
+ return { web, repl };
105
+ })();
106
+ return enginePromise;
107
+ }
108
+
109
+ /** Hear about a pattern that did not compile — evaluate() reports it here instead of throwing. */
110
+ export function onPatternError(fn) {
111
+ listeners.add(fn);
112
+ return () => listeners.delete(fn);
113
+ }
114
+
115
+ /**
116
+ * Compile and start a pattern. `onEvent` (optional) receives every sounding event as
117
+ * `(hap, time, cps, targetTime)` so the interface can light the step that is playing;
118
+ * the `false` keeps the engine's own sound output in place beside the callback.
119
+ */
120
+ export async function play(code, onEvent) {
121
+ // Still inside the press: a context the browser suspended while the tab was away resumes here.
122
+ if (audioContext?.state === "suspended") audioContext.resume();
123
+ const { web, repl } = await loadStrudel();
124
+ const pattern = await web.evaluate(code, false);
125
+ if (!pattern) return null;
126
+ const withEvents = onEvent ? pattern.onTrigger(onEvent, false) : pattern;
127
+ await repl.setPattern(withEvents, true);
128
+ return pattern;
129
+ }
130
+
131
+ /** Silence everything the engine is playing. Safe to call before the engine ever loaded. */
132
+ export async function stop() {
133
+ if (!enginePromise) return;
134
+ const { web } = await enginePromise;
135
+ web.hush();
136
+ }
137
+
138
+ /** Tempo in beats per minute for a 4/4 feel: cpm counts cycles (whole bars), so bpm/4. */
139
+ export function cpm(bpm, beatsPerCycle = 4) {
140
+ return `.cpm(${bpm}/${beatsPerCycle})`;
141
+ }
142
+
143
+ /**
144
+ * Mini-notation from a step grid: a lit step is its sound, an unlit one is `~`. The grid is read
145
+ * as ONE cycle of `beatsPerCycle` beats however many steps it has (8 steps are eighth notes, 16 are
146
+ * sixteenths of one 4/4 bar), so the tempo stays the bpm the person set.
147
+ */
148
+ export function gridToCode(grid, rows, bpm = 110, beatsPerCycle = 4) {
149
+ const lines = rows.map((sound) => ` s("${grid[sound].map((on) => (on ? sound : "~")).join(" ")}")`);
150
+ return `stack(\n${lines.join(",\n")}\n)${cpm(bpm, beatsPerCycle)}`;
151
+ }
152
+
153
+ export const EXAMPLES = [
154
+ { name: "four on the floor", code: `s("bd*4, ~ cp ~ cp, hh*8").cpm(120/4)` },
155
+ { name: "dusty boom bap", code: `stack(\n s("bd ~ [~ bd] ~, ~ sd ~ sd, hh*8"),\n note("<c2 c2 eb2 g1>").s("sawtooth").lpf(600).gain(0.6)\n).cpm(90/4)` },
156
+ { name: "acid line", code: `note("c2 [eb2 c2] g1 [c2 bb1]").s("sawtooth")\n .lpf(sine.range(200, 2000).slow(4))\n .resonance(10).gain(0.7).cpm(130/4)` },
157
+ { name: "polyrhythm", code: `stack(\n s("bd(3,8)"),\n s("hh(7,16)").gain(0.5),\n note("c3 eb3 g3 bb3").s("triangle").slow(2).room(0.5)\n).cpm(100/4)` },
158
+ ];
159
+ ```
160
+
161
+ The prompt the rewrite box sends. It is the verbatim system prompt for
162
+ turning a sentence into a pattern (its own section below); the module
163
+ exports it so any app can hand it to `callAI`.
164
+
165
+ ### `lib/pattern-prompt.js`
166
+ ```js
167
+ // lib/pattern-prompt.js — the system prompt for turning a sentence into a Strudel pattern.
168
+ // Hand it to callAI with the person's request and the current pattern; read back { name, code }.
169
+ export const PATTERN_PROMPT = `You write Strudel patterns. Strudel is a live-coding music language. A pattern is ONE JavaScript expression that loops forever over a cycle.
170
+
171
+ MINI-NOTATION (the text inside quotes). Events are separated by spaces and share the cycle equally.
172
+ "~" is a rest. "a*2" plays a twice in its slot. "a/2" plays a once every two cycles. "[a b]" fits two events into one slot. "<a b>" alternates: a this cycle, b the next. "a(3,8)" spreads 3 hits over 8 steps. "a, b" plays both lines at once. "a!3" repeats a across three slots. "a?" plays a about half the time.
173
+
174
+ DRUMS. s("…") names drum samples. Always available: bd (kick), sd (snare), hh (closed hat), cp (clap), cr (crash), lt mt ht (toms), perc. With .bank("RolandTR808") or .bank("RolandTR909") on the pattern, also oh (open hat) and rim, in that machine's voice.
175
+ NOTES. note("c3 e3 g3 b3").s("sawtooth") plays a synth; voices are sine, triangle, square, sawtooth. A note is a letter, an optional # or b, and an octave from 1 to 6. n("0 2 4").scale("C4:minor") picks scale degrees instead.
176
+
177
+ EFFECTS chain on the end: .gain(0 to 1) volume, .pan(0 to 1), .lpf(hz) low-pass from 100 to 8000, .hpf(hz), .resonance(0 to 30), .room(0 to 1) reverb, .delay(0 to 1), .speed(n) sample pitch, .slow(n) stretches a pattern over n cycles, .fast(n) fits n repeats into one, .rev() reverses, .jux(rev) plays a reversed copy in the other ear, .every(n, f) applies f every nth cycle, .sometimes(f) applies f at random. A number can be a slow sweep: sine.range(200, 2000).slow(4).
178
+
179
+ COMBINING. stack(a, b, c) plays patterns together. cat(a, b) plays them one cycle after another.
180
+
181
+ TEMPO. Put .cpm(cycles per minute) on the outermost pattern. One cycle is one pass through the string, so count the beats the string holds: a four-beat bar at 120 bpm is .cpm(120/4); an eight-beat phrase at 120 bpm is .cpm(120/8). Leaving .cpm off means 120 bpm in 4/4.
182
+
183
+ OUTPUT. Exactly one expression. No imports, no variables, no comments, no prose. Double quotes inside s(), note() and n(). Only the sound names listed above.
184
+
185
+ EXAMPLES
186
+ Request: a slow dusty hip hop beat
187
+ Pattern: stack(s("bd ~ [~ bd] ~, ~ sd ~ sd, hh*8").bank("RolandTR808"), note("<c2 c2 eb2 g1>").s("sawtooth").lpf(600).gain(0.6)).cpm(88/4)
188
+ Request: fast techno, open hat on the offbeat
189
+ Pattern: stack(s("bd*4"), s("~ oh ~ oh").gain(0.6), s("hh*16").gain(0.3)).bank("RolandTR909").cpm(138/4)
190
+ Request: a gentle arpeggio in a minor key
191
+ Pattern: n("0 2 4 7 4 2").scale("C4:minor").s("triangle").room(0.6).slow(2).cpm(100/4)
192
+ Request: a two-bar phrase where the snare moves on the second bar
193
+ Pattern: stack(s("bd*4 bd*4"), s("~ sd ~ sd ~ sd sd ~"), s("hh*16")).cpm(120/8)`;
194
+
195
+ /** The full prompt for one request: the spec, the ask, and the pattern that is playing now. */
196
+ export function rewritePrompt(request, currentCode) {
197
+ return `${PATTERN_PROMPT}\n\nRequest: ${request}\nCurrent pattern (change it, or replace it when the request asks for something new):\n${currentCode}\n\nReturn JSON with "name" (two or three words) and "code" (the pattern).`;
198
+ }
199
+ ```
200
+
201
+ The interface: an editor, example chips, play and stop, a prompt box that
202
+ asks `callAI` to rewrite the pattern, and the saved patterns from Fireproof
203
+ with every write gated on `can`.
204
+
205
+ ### `App.jsx`
206
+ ```jsx
207
+ import React, { useEffect, useMemo, useState } from "react";
208
+ import { useFireproof } from "use-fireproof";
209
+ import { callAI, useVibe, useViewer } from "use-vibes";
210
+ import { Badge, Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
211
+ import { EXAMPLES, onPatternError, play, stop } from "./lib/strudel.js";
212
+ import { rewritePrompt } from "./lib/pattern-prompt.js";
213
+
214
+ const FIRST = `// ${EXAMPLES[0].name}\n${EXAMPLES[0].code}`;
215
+
216
+ export default function App() {
217
+ const { database, useLiveQuery } = useFireproof("patternPad");
218
+ const { me, can, ready } = useVibe("patternPad");
219
+ const { requestLogin, ViewerTag } = useViewer();
220
+ const { docs } = useLiveQuery("type", { key: "pattern" });
221
+ const saved = useMemo(() => [...docs].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)), [docs]);
222
+
223
+ const [code, setCode] = useState(FIRST);
224
+ const [state, setState] = useState("idle"); // idle | loading | playing
225
+ const [problem, setProblem] = useState(null);
226
+ const [ask, setAsk] = useState("");
227
+ const [asking, setAsking] = useState(false);
228
+ const [name, setName] = useState("");
229
+
230
+ useEffect(() => onPatternError((error) => setProblem(String(error.message || error))), []);
231
+ useEffect(() => () => void stop(), []);
232
+
233
+ async function handlePlay(source = code) {
234
+ setProblem(null);
235
+ const wasPlaying = state === "playing"; // read once: `state` is this render's value
236
+ if (!wasPlaying) setState("loading");
237
+ const pattern = await play(source);
238
+ if (pattern) setState("playing");
239
+ else if (!wasPlaying) setState("idle");
240
+ }
241
+
242
+ async function handleStop() {
243
+ await stop();
244
+ setState("idle");
245
+ }
246
+
247
+ function pick(example) {
248
+ const next = `// ${example.name}\n${example.code}`;
249
+ setCode(next);
250
+ if (state === "playing") handlePlay(next);
251
+ }
252
+
253
+ async function rewrite() {
254
+ if (!ask.trim() || asking) return;
255
+ setAsking(true);
256
+ const reply = await callAI(rewritePrompt(ask, code), {
257
+ schema: { properties: { name: { type: "string" }, code: { type: "string" } } },
258
+ });
259
+ const next = JSON.parse(reply);
260
+ const source = `// ${next.name}\n${next.code}`;
261
+ setCode(source);
262
+ setAsk("");
263
+ setAsking(false);
264
+ if (state === "playing") handlePlay(source);
265
+ }
266
+
267
+ const draft = { type: "pattern", name: name || "untitled", code, authorHandle: me?.userHandle };
268
+ const verdict = can.create(draft);
269
+
270
+ async function keep() {
271
+ await database.put({ ...draft, createdAt: Date.now() });
272
+ setName("");
273
+ }
274
+
275
+ return (
276
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
277
+ <Stack gap={4}>
278
+ <Row gap={2} align="center" wrap>
279
+ <Heading level={1}>Pattern Pad</Heading>
280
+ <Spacer />
281
+ <Badge tone={state === "playing" ? "up" : "idle"}>{state === "playing" ? "Playing" : state === "loading" ? "Fetching the drum kit…" : "Ready"}</Badge>
282
+ </Row>
283
+
284
+ <textarea
285
+ value={code}
286
+ onChange={(e) => setCode(e.target.value)}
287
+ onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); handlePlay(); } }}
288
+ spellCheck={false}
289
+ rows={8}
290
+ className="w-full rounded-md border border-border bg-card p-3 font-mono text-sm text-foreground outline-none"
291
+ />
292
+ {problem && <Text size="sm" tone="down">That pattern needs a tweak: {problem}. The last good one keeps playing.</Text>}
293
+
294
+ <Row gap={2} align="center" wrap>
295
+ <Button onClick={() => handlePlay()}>{state === "playing" ? "Replay" : "Play"}</Button>
296
+ <Button variant="quiet" onClick={handleStop}>Stop</Button>
297
+ <Text size="sm" muted>Ctrl+Enter plays the editor</Text>
298
+ </Row>
299
+
300
+ <Row gap={2} wrap>
301
+ {EXAMPLES.map((ex) => (
302
+ <Button key={ex.name} size="sm" variant="quiet" onClick={() => pick(ex)}>{ex.name}</Button>
303
+ ))}
304
+ </Row>
305
+
306
+ <Card variant="tight">
307
+ <Row gap={2} align="top" wrap>
308
+ <Field label="Ask for a change">{(id, d) => <Input id={id} describedBy={d} value={ask} onChange={setAsk} placeholder="make it darker and slower" />}</Field>
309
+ <Button size="sm" onClick={rewrite} disabled={asking}>{asking ? "Rewriting…" : "Rewrite"}</Button>
310
+ </Row>
311
+ </Card>
312
+
313
+ <Card variant="tight">
314
+ <Stack gap={3}>
315
+ <Heading level={2}>Keep this pattern</Heading>
316
+ {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
317
+ {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your patterns stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in to keep patterns</Button></Row>}
318
+ {ready && me && verdict.ok && (
319
+ <Row gap={2} align="top" wrap>
320
+ <Field label="Name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="late night groove" />}</Field>
321
+ <Button size="sm" onClick={keep}>Keep</Button>
322
+ </Row>
323
+ )}
324
+ {ready && me && !verdict.ok && !verdict.pending && <Text muted>Patterns here are kept by the pad's owner; remix the pad to keep your own.</Text>}
325
+ {saved.map((p) => (
326
+ <Row key={p._id} gap={2} align="center" wrap>
327
+ <Button size="sm" variant="quiet" onClick={() => { setCode(p.code); if (state === "playing") handlePlay(p.code); }}>{p.name}</Button>
328
+ {p.authorHandle && <ViewerTag userHandle={p.authorHandle} />}
329
+ <Spacer />
330
+ {can.delete(p).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(p._id)}>Remove</Button>}
331
+ </Row>
332
+ ))}
333
+ </Stack>
334
+ </Card>
335
+
336
+ <Text size="sm" muted>
337
+ Sound by <a className="underline" href="https://strudel.cc" target="_blank" rel="noreferrer">Strudel</a> (AGPL), fetched from its own address when you press play.
338
+ </Text>
339
+ </Stack>
340
+ </main>
341
+ );
342
+ }
343
+ ```
344
+
345
+ ### `access.js`
346
+ ```js
347
+ // pattern: a saved groove. Anyone signed in keeps their own; everyone can read the gallery.
348
+ export function patternPad(doc, oldDoc, user, ctx) {
349
+ if (!user) throw { forbidden: "sign in to keep patterns" };
350
+ if (doc.type === "pattern" || (doc._deleted && oldDoc && oldDoc.type === "pattern")) {
351
+ const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
352
+ if (author !== user.userHandle) throw { forbidden: "sign your pattern with your own handle" };
353
+ return { channels: ["patterns"], grant: { public: ["patterns"] } };
354
+ }
355
+ throw { forbidden: "unknown document type" };
356
+ }
357
+ ```
358
+
359
+ ## Mini-notation — the whole language in a dozen symbols
360
+
361
+ A pattern string loops over one **cycle**; spaces divide the cycle into
362
+ equal slots. Words are sounds (`s("bd sd hh cp")`) or notes
363
+ (`note("c3 e3 g3")`).
364
+
365
+ | write | meaning |
366
+ | -------------- | ---------------------------------------------- |
367
+ | `bd sd` | kick, then snare |
368
+ | `~` | silence for that slot |
369
+ | `bd*2` | twice in the slot |
370
+ | `bd/2` | once every two cycles |
371
+ | `[sd sd]` | two squeezed into one slot |
372
+ | `<sd cp>` | snare this cycle, clap the next |
373
+ | `bd(3,8)` | three hits spread over eight steps (Euclidean) |
374
+ | `bd sd, hh*8` | both lines at once |
375
+ | `bd!3 sd` | the same sound three slots running |
376
+ | `hh? hh?` | each hit plays about half the time |
377
+
378
+ Builders: `stack(a, b)` plays patterns together; `cat(a, b)` plays them one
379
+ cycle after another; `note("c3 e3").s("sawtooth")` chooses a synth voice
380
+ (`sine`, `square`, `triangle`, `sawtooth`); `s("bd oh rim").bank("RolandTR808")`
381
+ plays those drums in that machine's voice. Effects chain on the end: `.gain(0.6)`,
382
+ `.pan(0.3)`, `.lpf(800)`, `.hpf(200)`, `.resonance(10)`, `.room(0.4)`,
383
+ `.delay(0.25)`, `.slow(2)`, `.fast(2)`, `.rev()`, `.jux(rev)`,
384
+ `.every(4, x => x.fast(2))`, `.sometimes(x => x.speed(2))`. A signal such as
385
+ `sine.range(200, 2000).slow(4)` turns any number into a slow sweep.
386
+
387
+ ## Tempo, cycles and loop length — worked examples
388
+
389
+ **Tempo is in cycles: `.cpm(bpm/4)` for a 4/4 feel.** Strudel has no bpm
390
+ setting. `cpm` is cycles per minute, and a cycle is one pass through the
391
+ pattern string, so the tempo a listener hears depends on how many beats the
392
+ string holds. The engine's default is 0.5 cycles per second (`.cpm(30)`),
393
+ which reads as 120 bpm when the string is one bar of four beats. Measured
394
+ 2026-10-07 with `onTrigger` on the real engine: `s("bd*4").cpm(120/4)` hits
395
+ every 0.500 s, which is 120 bpm.
396
+
397
+ The arithmetic, once: **cpm = bpm ÷ beats per cycle.** Decide how many beats
398
+ the string spans, then divide.
399
+
400
+ | the string | beats it holds | at 120 bpm | why |
401
+ | ------------------------------------------- | -------------- | ---------------- | ----------------------------------------------------- |
402
+ | `s("bd*4")` or `s("bd sd hh cp")` | 4 | `.cpm(120/4)` | one bar, four quarter notes |
403
+ | `s("bd*4 bd*4")` or an 8-step grid of quarters | 8 | `.cpm(120/8)` | two bars; each hit still lands every 0.5 s (measured) |
404
+ | an 8-step grid read as eighth notes | 4 | `.cpm(120/4)` | one bar, twice as dense |
405
+ | a 16-step grid of sixteenths | 4 | `.cpm(120/4)` | one bar; 16 slots in 2 s |
406
+ | `cat(a, b)` where each is one bar | 4 per cycle | `.cpm(120/4)` | two cycles, one bar each |
407
+ | `note("<c2 eb2>")` under a one-bar beat | 4 | `.cpm(120/4)` | `<>` alternates across cycles; it adds no beats |
408
+
409
+ Two rules fall out of the table:
410
+
411
+ - **The same grid can be two tempos.** Eight steps are one bar of eighths or
412
+ two bars of quarters; the app decides, names it in the UI ("8 steps = one
413
+ bar"), and divides by that number. `gridToCode` reads any grid as one bar
414
+ of four beats, so 8 steps are eighths and 16 are sixteenths; a grid meant
415
+ as two bars passes `beatsPerCycle = 8`.
416
+ - **`.slow(n)` and `.fast(n)` change a line's length and leave the tempo alone.**
417
+ `note("c3 eb3 g3 bb3").slow(2)` under `s("bd*4")` spreads four notes over
418
+ two bars while the drums keep their bar; `.fast(2)` fits the phrase into
419
+ half a bar. Change `.cpm` only when the whole piece should speed up.
420
+
421
+ A tempo slider sets `.cpm(${bpm}/4)` on the outer pattern and re-evaluates,
422
+ or calls `repl.setCps(bpm / 60 / 4)` to change the tempo of everything
423
+ playing without a re-evaluate (`cps` is cycles per second, so bpm ÷ 60 ÷
424
+ beats per cycle). A bpm pasted straight into `.cpm(120)` plays four times
425
+ too fast; the lane reports that shape without failing on it, since a bare
426
+ literal is right when the string holds one or two beats (`s("bd sd").cpm(60)`
427
+ is 120 bpm).
428
+
429
+ ## The interface writes the pattern
430
+
431
+ The pad above shows the editor shape. The other shape is the one most people
432
+ want: **buttons, grids and sliders that compile to a pattern string** and
433
+ re-evaluate it while it plays. A step grid is one function (`gridToCode`
434
+ above); sliders are numbers dropped into the same string (`toCode({ tempo,
435
+ filter, space })` returning `` `stack(…).lpf(${filter}).room(${space}).cpm(${tempo}/8)` ``);
436
+ a prompt box hands `rewritePrompt(request, code)` to `callAI` with a
437
+ `{ name, code }` schema and plays what comes back. Every
438
+ change calls `play(next)` when the engine is playing, and the engine swaps
439
+ patterns in time without a stop.
440
+
441
+ A groovebox whose lights follow the music, built on the same module:
442
+
443
+ ### `App.jsx`
444
+ ```jsx app=groovebox
445
+ import React, { useEffect, useRef, useState } from "react";
446
+ import { useFireproof } from "use-fireproof";
447
+ import { useVibe, useViewer } from "use-vibes";
448
+ import { Badge, Button, Card, Field, Heading, Icon, IconButton, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
449
+ import { Slider } from "@vibes.diy/pouf/interactive";
450
+ import { gridToCode, play, stop } from "./lib/strudel.js";
451
+
452
+ const ROWS = ["bd", "sd", "hh", "cp"];
453
+ const LABELS = { bd: "kick", sd: "snare", hh: "hat", cp: "clap" };
454
+ const START = {
455
+ bd: [1, 0, 0, 0, 1, 0, 0, 0],
456
+ sd: [0, 0, 1, 0, 0, 0, 1, 0],
457
+ hh: [1, 1, 1, 1, 1, 1, 1, 1],
458
+ cp: [0, 0, 0, 0, 0, 0, 1, 0],
459
+ };
460
+
461
+ export default function App() {
462
+ const { database, useLiveQuery } = useFireproof("groovebox");
463
+ const { me, can, ready } = useVibe("groovebox");
464
+ const { requestLogin, ViewerTag } = useViewer();
465
+ const { docs: grooves } = useLiveQuery("type", { key: "groove" });
466
+
467
+ const [grid, setGrid] = useState(START);
468
+ const [bpm, setBpm] = useState(110);
469
+ const [playing, setPlaying] = useState(false);
470
+ const [loading, setLoading] = useState(false);
471
+ const [step, setStep] = useState(-1);
472
+ const [name, setName] = useState("");
473
+ const timers = useRef([]);
474
+
475
+ useEffect(() => () => void stop(), []);
476
+
477
+ // Every sounding event names its slot; the light moves when the sound does.
478
+ function onEvent(hap, time, cps, target) {
479
+ const slot = Math.round(hap.whole.begin.valueOf() * 8) % 8;
480
+ const delayMs = Math.max(0, (target - time) * 1000);
481
+ timers.current.push(setTimeout(() => setStep(slot), delayMs));
482
+ }
483
+
484
+ async function start(nextGrid = grid, nextBpm = bpm) {
485
+ if (!playing) setLoading(true);
486
+ const pattern = await play(gridToCode(nextGrid, ROWS, nextBpm), onEvent);
487
+ setLoading(false);
488
+ if (pattern) setPlaying(true);
489
+ }
490
+
491
+ async function halt() {
492
+ await stop();
493
+ for (const t of timers.current) clearTimeout(t);
494
+ timers.current = [];
495
+ setPlaying(false);
496
+ setStep(-1);
497
+ }
498
+
499
+ function toggle(sound, i) {
500
+ const next = { ...grid, [sound]: grid[sound].map((v, j) => (j === i ? (v ? 0 : 1) : v)) };
501
+ setGrid(next);
502
+ if (playing) start(next, bpm);
503
+ }
504
+
505
+ function tempo([value]) {
506
+ setBpm(value);
507
+ if (playing) start(grid, value);
508
+ }
509
+
510
+ const draft = { type: "groove", name: name || "untitled", grid, bpm, authorHandle: me?.userHandle };
511
+ const verdict = can.create(draft);
512
+
513
+ return (
514
+ <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
515
+ <Stack gap={4}>
516
+ <Row gap={2} align="center" wrap>
517
+ <Heading level={1}>Groovebox</Heading>
518
+ <Spacer />
519
+ <Badge tone={playing ? "up" : "idle"}>{playing ? "Playing" : loading ? "Fetching the drum kit…" : "Ready"}</Badge>
520
+ </Row>
521
+
522
+ <Card variant="tight">
523
+ <Stack gap={2}>
524
+ {ROWS.map((sound) => (
525
+ <Row key={sound} gap={2} align="center">
526
+ <Text size="sm" muted>{LABELS[sound]}</Text>
527
+ <Spacer />
528
+ {grid[sound].map((on, i) => (
529
+ <IconButton
530
+ key={i}
531
+ size="sm"
532
+ label={`${LABELS[sound]} step ${i + 1}${on ? ", on" : ", off"}`}
533
+ variant={on ? "solid" : "quiet"}
534
+ tone={i === step ? "info" : on ? "up" : "idle"}
535
+ icon={<Icon name={on ? "idle" : "off"} size="sm" />}
536
+ onClick={() => toggle(sound, i)}
537
+ />
538
+ ))}
539
+ </Row>
540
+ ))}
541
+ </Stack>
542
+ </Card>
543
+
544
+ <Row gap={3} align="center" wrap>
545
+ <Button onClick={() => (playing ? halt() : start())}>{playing ? "Stop" : "Play"}</Button>
546
+ <Slider value={[bpm]} onChange={tempo} min={60} max={180} label="Tempo" />
547
+ <Text size="sm" muted>{bpm} bpm</Text>
548
+ </Row>
549
+
550
+ <Card variant="tight">
551
+ <Stack gap={3}>
552
+ <Heading level={2}>Shared grooves</Heading>
553
+ {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
554
+ {ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in to publish a groove to the shared book.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
555
+ {ready && me && verdict.ok && (
556
+ <Row gap={2} align="top" wrap>
557
+ <Field label="Groove name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} />}</Field>
558
+ <Button size="sm" onClick={() => database.put({ ...draft, createdAt: Date.now() }).then(() => setName(""))}>Publish</Button>
559
+ </Row>
560
+ )}
561
+ {grooves.map((g) => (
562
+ <Row key={g._id} gap={2} align="center" wrap>
563
+ <Button size="sm" variant="quiet" onClick={() => { setGrid(g.grid); setBpm(g.bpm); if (playing) start(g.grid, g.bpm); }}>{g.name}</Button>
564
+ {g.authorHandle && <ViewerTag userHandle={g.authorHandle} />}
565
+ </Row>
566
+ ))}
567
+ </Stack>
568
+ </Card>
569
+
570
+ <Text size="sm" muted>
571
+ Sound by <a className="underline" href="https://strudel.cc" target="_blank" rel="noreferrer">Strudel</a> (AGPL), fetched from its own address when you press play.
572
+ </Text>
573
+ </Stack>
574
+ </main>
575
+ );
576
+ }
577
+ ```
578
+
579
+ ## The text-to-pattern prompt, verbatim
580
+
581
+ `lib/pattern-prompt.js` above is the system prompt for a text box that
582
+ turns a sentence into a pattern, or an app that rewrites the pattern on
583
+ request. Use it as written: it names only sounds the two sample maps hold,
584
+ its four examples were played on the engine as the doc was written, and it
585
+ states the tempo rule in the form the model gets right (count the beats the
586
+ string holds, then divide). `rewritePrompt(request, code)` appends the ask
587
+ and the pattern that is playing; the `callAI` schema is
588
+ `{ name: string, code: string }`, and the app plays `code` through `play()`
589
+ so a pattern the model got wrong still surfaces through `onPatternError`
590
+ rather than silence. For a text box with no current pattern, pass the
591
+ starter example as `code`.
592
+
593
+ ## The interface follows the music
594
+
595
+ **Light the step that is sounding from the engine's own events, with
596
+ `pattern.onTrigger(callback, false)`.** The callback receives
597
+ `(hap, time, cps, targetTime)`: `hap.whole.begin` is the event's position
598
+ in cycles (so `begin * steps % steps` is its slot), `time` is the audio
599
+ clock now and `targetTime` is when the sound plays, so a `setTimeout` of
600
+ `(targetTime - time) * 1000` lands the light on the beat. The `false` keeps
601
+ the engine's own sound output in place; the default replaces it, which is
602
+ how a visualiser goes silent. Clear the pending timers in `stop`.
603
+
604
+ ## Play, stop and the lifecycle
605
+
606
+ - **Stop calls `hush()`**, which silences every pattern the engine holds
607
+ and halts the scheduler; `play(nextCode)` after a stop starts it again
608
+ (measured: `started` reads false after `hush()`, true after the next
609
+ `setPattern`). A `useEffect` cleanup that calls `stop()` keeps a navigated-
610
+ away page quiet.
611
+ - A change while playing calls `play(next)` again; the engine swaps patterns
612
+ without a gap. Keep `playing` in state (or a ref the handlers read) so a
613
+ toggle on a stopped grid changes the grid and nothing else.
614
+ - The first play shows a short loading state with warm copy ("fetching the
615
+ drum kit") and the button stays pressable; from the second play on the
616
+ engine is already here.
617
+ - A bad pattern arrives through `onPatternError`: show the message in the
618
+ app's words beside the editor, keep the editor's text, and let the last
619
+ good pattern play on. `play()` returns `null` for it, so the state stays
620
+ where it was.
621
+
622
+ ## Persistence — the pattern text is the document
623
+
624
+ **Save the pattern text itself** (`code`, or the `grid` and `bpm` a grid
625
+ compiles from) with a name, the author's handle and a timestamp; the engine
626
+ rebuilds the sound from it on every play, so a saved document is tiny and
627
+ remixable, and a change to the generator reaches every saved groove. Seed
628
+ three or four finished patterns in `seed.json` so the first visit has
629
+ something to press. The shared gallery is one `useLiveQuery` over the
630
+ `pattern` type, and the access rule above makes it public to read and
631
+ author-owned to write; a signed-out visitor jams on the pad and is invited
632
+ to sign in at "Keep".
633
+
634
+ ## Samples and voices
635
+
636
+ Two sample maps arrive in `prebake`, both from jsDelivr. The Dirt-Samples
637
+ kit names the drums a bare `s("…")` can play: `bd`, `sd`, `hh`, `cp`, `cr`,
638
+ `lt`, `mt`, `ht`, `perc`, plus named one-offs such as `808bd`, `808oh`,
639
+ `jvbass`, `casio`, `tabla`, `arpy`. The tidal-drum-machines map adds the
640
+ banks: `.bank("RolandTR808")`, `.bank("RolandTR909")`, `.bank("LinnDrum")`
641
+ and dozens more, each with `bd sd hh oh cp rim cr lt mt ht`. Measured
642
+ 2026-10-07: with only the Dirt kit loaded, `oh` and `rim` play nothing and
643
+ the engine logs "sound oh not found" to the console; with both maps loaded
644
+ every name above plays. The engine's own synths need no download:
645
+ `note("c3 e3 g3").s("sawtooth")`. The app's own sounds load the same way as
646
+ the kits: `samples({ kick: "https://…/kick.wav", snare: "…" })` inside
647
+ `prebake`, then `s("kick snare")`.
648
+
649
+ ## Mobile
650
+
651
+ Play is a large button near the top; nothing sounds until it is pressed. A
652
+ grid's steps are `IconButton`s whose label names the row, the step and its
653
+ state, and a tempo slider sits beside play rather than in a settings drawer. The loader makes
654
+ and resumes the audio context inside the tap, which is what iOS asks of sound.
655
+
656
+ ## Shapes a Strudel vibe takes
657
+
658
+ - **A pattern pad** — editor, examples, play and stop, saved patterns, a
659
+ prompt box that rewrites the groove. The starter above.
660
+ - **A groovebox** — a grid of pads that writes mini-notation, a tempo
661
+ slider, lights that follow the music, a shared groovebook with an access
662
+ rule. The second example above.
663
+ - **A knob instrument** — three sliders (tempo, brightness, space) over one
664
+ fixed pattern, re-evaluated on every drag.
665
+ - **A generative radio** — `cat` of several patterns with `.sometimes` and
666
+ `.every` variations and a seed, playing for as long as the tab is open.
667
+ - **A jam room** — the pattern document is shared, every member's edit is a
668
+ version, and the gallery doubles as the set list.
669
+
670
+ ## Common gotchas
671
+
672
+ - **Tempo is twice, half or four times what was meant** — `cpm` counts
673
+ cycles. Divide the bpm by the beats one cycle holds (the table above).
674
+ - **A drum name plays nothing and the console says "sound X not found"** —
675
+ the name is in a bank, not the bare kit. Load both sample maps and use
676
+ `.bank(…)`, or pick a bare-kit name.
677
+ - **The music stops when a visualiser is added** — `onTrigger` with its
678
+ default second argument replaces the sound output. Pass `false`.
679
+ - **A typo stops nothing and shows nothing** — `evaluate` reports through
680
+ `onEvalError`; wire it in `initStrudel` and show the message.
681
+ - **The engine loads twice** — two loaders, or an inline `import()` in a
682
+ handler. One memoized module-level loader.
683
+ - **A pattern built from typed text breaks on a quote** — strip or escape
684
+ `"` before interpolating a person's text into `s("…")`.
685
+ - **Sound carries on after leaving the page** — call `stop()` in the
686
+ effect cleanup.