Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

Strudel — live-coding music from text patterns

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/strudel.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

When someone asks for live coding, Strudel or Tidal, mini-notation, an algorave page, "type a beat and hear it", a pattern jam, generative or algorithmic music built from little text patterns, a groovebox whose buttons write a pattern, or a page where a model rewrites the music on request, this is the recipe. Strudel is a music engine that plays patterns written as text: s("bd*4, ~ cp ~ cp, hh*8") is a whole groove, and a pattern can be typed by a person, written by an interface, saved as a document, remixed, or produced by callAI from a sentence. The engine arrives from the CDN the first time someone presses play, so the app ships only its own code.

The sibling boundary: a synth built from oscillators, a drum machine with its own clock, a Tone.js instrument, mic monitoring, a metronome — those are the web-audio skill. Strudel is the right call when the music is a pattern expressed as text (or compiled into text from a grid, sliders or a prompt).

The worked examples are jchris/strudel (an editor, saved patterns and a prompt box that rewrites the groove), jchris/strudel-groovebook (a step grid whose toggles write mini-notation, with a shared public gallery), and the four tutorial vibes jchris/strudel-hello-beat, strudel-notation, strudel-layers and strudel-effects behind the "You can just make music" posts on good.vibes.diy.

The engine arrives at play time

Import the engine with its full pinned esm.sh URL inside a memoized module-level loader, and call that loader from the play gesture. The import is a body-position await import("https://esm.sh/@strudel/[email protected]"): the platform rewrites bare specifiers only in the top-of-file import region, so a lazy import carries the complete versioned URL. Laziness is the point — first paint owes nothing to a 165 KB engine plus a drum kit, and the AudioContext wants a user gesture anyway, which the play button is. The loader makes the AudioContext itself, synchronously, before its first await, resumes it, and hands it to the engine with setAudioContext ahead of initStrudel. The engine's own first-click unlock is registered only once the download finishes, by which time the press that started it has passed; a context made and resumed inside that press is what iOS asks of sound (measured 2026-10-07: the handed-in context is the one the engine plays through, running, events firing after a 1.7 s load).

Strudel is AGPL-licensed. The app links to the engine at its own address the way a browser links to any page, so the two programs stay separate: credit Strudel visibly (a footer line with a link to strudel.cc is the house shape), and keep its code at its URL rather than copied into the app.

The drum kit is the Dirt-Samples bank, loaded in prebake from the jsDelivr mirror (https://cdn.jsdelivr.net/gh/tidalcycles/Dirt-Samples@master/strudel.json); jsDelivr serves it with CORS and without rate limits. The first play fetches the engine and the kit together (measured 2026-10-07: about two seconds in Chromium), so the play button shows a short "fetching the drum kit" state on that first press and plays at once ever after.

evaluate() reports a pattern that did not compile through onEvalError rather than by throwing (measured: a syntax error logs to the console, the call resolves with nothing, and the previous pattern keeps playing). Pass onEvalError to initStrudel and show its message in the app's own words, beside the editor, while the last good pattern plays on.

Project shape

App.jsx          the interface: editor or grid, play and stop, saved patterns, the credit line
lib/strudel.js   the loader, play(), stop(), the error hook, helpers that write mini-notation, EXAMPLES
access.js        who may publish a pattern to the shared gallery
seed.json        three or four finished patterns so the first visit has something to press

Relative imports include the extension (./lib/strudel.js). Everything that touches the engine lives in the module, so App.jsx reads as an interface.

Complete starter — a pattern pad

The engine module. play(code, onEvent) compiles a pattern and starts it; the optional onEvent callback receives every sounding event so the interface can light up with the music; stop() hushes; onPatternError delivers the message a bad pattern produces.

lib/strudel.js

jsconst ENGINE_URL = "https://esm.sh/@strudel/[email protected]";
const DRUM_KIT_URL = "https://cdn.jsdelivr.net/gh/tidalcycles/Dirt-Samples@master/strudel.json";
const DRUM_MACHINES_URL = "https://cdn.jsdelivr.net/gh/felixroos/dough-samples@main/tidal-drum-machines.json";

let enginePromise;
let audioContext;
const listeners = new Set();

/** One fetch of the engine and the drum kit, however many times play is pressed. */
export function loadStrudel() {
  enginePromise ??= (async () => {
    // Runs inside the press that called play(): the context is made and resumed in the gesture.
    audioContext = new AudioContext();
    audioContext.resume();
    const web = await import(ENGINE_URL);
    web.setAudioContext(audioContext);
    const repl = await web.initStrudel({
      prebake: () => Promise.all([web.samples(DRUM_KIT_URL), web.samples(DRUM_MACHINES_URL)]),
      onEvalError: (error) => {
        for (const fn of listeners) fn(error);
      },
    });
    return { web, repl };
  })();
  return enginePromise;
}

/** Hear about a pattern that did not compile — evaluate() reports it here instead of throwing. */
export function onPatternError(fn) {
  listeners.add(fn);
  return () => listeners.delete(fn);
}

/**
 * Compile and start a pattern. `onEvent` (optional) receives every sounding event as
 * `(hap, time, cps, targetTime)` so the interface can light the step that is playing;
 * the `false` keeps the engine's own sound output in place beside the callback.
 */
export async function play(code, onEvent) {
  // Still inside the press: a context the browser suspended while the tab was away resumes here.
  if (audioContext?.state === "suspended") audioContext.resume();
  const { web, repl } = await loadStrudel();
  const pattern = await web.evaluate(code, false);
  if (!pattern) return null;
  const withEvents = onEvent ? pattern.onTrigger(onEvent, false) : pattern;
  await repl.setPattern(withEvents, true);
  return pattern;
}

/** Silence everything the engine is playing. Safe to call before the engine ever loaded. */
export async function stop() {
  if (!enginePromise) return;
  const { web } = await enginePromise;
  web.hush();
}

/** Tempo in beats per minute for a 4/4 feel: cpm counts cycles (whole bars), so bpm/4. */
export function cpm(bpm, beatsPerCycle = 4) {
  return `.cpm(${bpm}/${beatsPerCycle})`;
}

/**
 * Mini-notation from a step grid: a lit step is its sound, an unlit one is `~`. The grid is read
 * as ONE cycle of `beatsPerCycle` beats however many steps it has (8 steps are eighth notes, 16 are
 * sixteenths of one 4/4 bar), so the tempo stays the bpm the person set.
 */
export function gridToCode(grid, rows, bpm = 110, beatsPerCycle = 4) {
  const lines = rows.map((sound) => `  s("${grid[sound].map((on) => (on ? sound : "~")).join(" ")}")`);
  return `stack(\n${lines.join(",\n")}\n)${cpm(bpm, beatsPerCycle)}`;
}

export const EXAMPLES = [
  { name: "four on the floor", code: `s("bd*4, ~ cp ~ cp, hh*8").cpm(120/4)` },
  { 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)` },
  { 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)` },
  { 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)` },
];

The prompt the rewrite box sends. It is the verbatim system prompt for turning a sentence into a pattern (its own section below); the module exports it so any app can hand it to callAI.

lib/pattern-prompt.js

js// lib/pattern-prompt.js — the system prompt for turning a sentence into a Strudel pattern.
// Hand it to callAI with the person's request and the current pattern; read back { name, code }.
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.

MINI-NOTATION (the text inside quotes). Events are separated by spaces and share the cycle equally.
"~" 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.

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.
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.

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).

COMBINING. stack(a, b, c) plays patterns together. cat(a, b) plays them one cycle after another.

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.

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.

EXAMPLES
Request: a slow dusty hip hop beat
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)
Request: fast techno, open hat on the offbeat
Pattern: stack(s("bd*4"), s("~ oh ~ oh").gain(0.6), s("hh*16").gain(0.3)).bank("RolandTR909").cpm(138/4)
Request: a gentle arpeggio in a minor key
Pattern: n("0 2 4 7 4 2").scale("C4:minor").s("triangle").room(0.6).slow(2).cpm(100/4)
Request: a two-bar phrase where the snare moves on the second bar
Pattern: stack(s("bd*4 bd*4"), s("~ sd ~ sd ~ sd sd ~"), s("hh*16")).cpm(120/8)`;

/** The full prompt for one request: the spec, the ask, and the pattern that is playing now. */
export function rewritePrompt(request, currentCode) {
  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).`;
}

The interface: an editor, example chips, play and stop, a prompt box that asks callAI to rewrite the pattern, and the saved patterns from Fireproof with every write gated on can.

App.jsx

jsximport React, { useEffect, useMemo, useState } from "react";
import { useFireproof } from "use-fireproof";
import { callAI, useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { EXAMPLES, onPatternError, play, stop } from "./lib/strudel.js";
import { rewritePrompt } from "./lib/pattern-prompt.js";

const FIRST = `// ${EXAMPLES[0].name}\n${EXAMPLES[0].code}`;

export default function App() {
  const { database, useLiveQuery } = useFireproof("patternPad");
  const { me, can, ready } = useVibe("patternPad");
  const { requestLogin, ViewerTag } = useViewer();
  const { docs } = useLiveQuery("type", { key: "pattern" });
  const saved = useMemo(() => [...docs].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)), [docs]);

  const [code, setCode] = useState(FIRST);
  const [state, setState] = useState("idle"); // idle | loading | playing
  const [problem, setProblem] = useState(null);
  const [ask, setAsk] = useState("");
  const [asking, setAsking] = useState(false);
  const [name, setName] = useState("");

  useEffect(() => onPatternError((error) => setProblem(String(error.message || error))), []);
  useEffect(() => () => void stop(), []);

  async function handlePlay(source = code) {
    setProblem(null);
    const wasPlaying = state === "playing"; // read once: `state` is this render's value
    if (!wasPlaying) setState("loading");
    const pattern = await play(source);
    if (pattern) setState("playing");
    else if (!wasPlaying) setState("idle");
  }

  async function handleStop() {
    await stop();
    setState("idle");
  }

  function pick(example) {
    const next = `// ${example.name}\n${example.code}`;
    setCode(next);
    if (state === "playing") handlePlay(next);
  }

  async function rewrite() {
    if (!ask.trim() || asking) return;
    setAsking(true);
    const reply = await callAI(rewritePrompt(ask, code), {
      schema: { properties: { name: { type: "string" }, code: { type: "string" } } },
    });
    const next = JSON.parse(reply);
    const source = `// ${next.name}\n${next.code}`;
    setCode(source);
    setAsk("");
    setAsking(false);
    if (state === "playing") handlePlay(source);
  }

  const draft = { type: "pattern", name: name || "untitled", code, authorHandle: me?.userHandle };
  const verdict = can.create(draft);

  async function keep() {
    setName(""); // ready for the next name at once
    const { ok } = await database.put({ ...draft, createdAt: Date.now() });
    if (!ok) setName((now) => now || name);
  }

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Row gap={2} align="center" wrap>
          <Heading level={1}>Pattern Pad</Heading>
          <Spacer />
          <Badge tone={state === "playing" ? "up" : "idle"}>{state === "playing" ? "Playing" : state === "loading" ? "Fetching the drum kit…" : "Ready"}</Badge>
        </Row>

        <textarea
          value={code}
          onChange={(e) => setCode(e.target.value)}
          onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); handlePlay(); } }}
          spellCheck={false}
          rows={8}
          className="w-full rounded-md border border-border bg-card p-3 font-mono text-sm text-foreground outline-none"
        />
        {problem && <Text size="sm" tone="down">That pattern needs a tweak: {problem}. The last good one keeps playing.</Text>}

        <Row gap={2} align="center" wrap>
          <Button onClick={() => handlePlay()}>{state === "playing" ? "Replay" : "Play"}</Button>
          <Button variant="quiet" onClick={handleStop}>Stop</Button>
          <Text size="sm" muted>Ctrl+Enter plays the editor</Text>
        </Row>

        <Row gap={2} wrap>
          {EXAMPLES.map((ex) => (
            <Button key={ex.name} size="sm" variant="quiet" onClick={() => pick(ex)}>{ex.name}</Button>
          ))}
        </Row>

        <Card variant="tight">
          <Row gap={2} align="top" wrap>
            <Field label="Ask for a change">{(id, d) => <Input id={id} describedBy={d} value={ask} onChange={setAsk} placeholder="make it darker and slower" />}</Field>
            <Button size="sm" onClick={rewrite} disabled={asking}>{asking ? "Rewriting…" : "Rewrite"}</Button>
          </Row>
        </Card>

        <Card variant="tight">
          <Stack gap={3}>
            <Heading level={2}>Keep this pattern</Heading>
            {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
            {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>}
            {ready && me && verdict.ok && (
              <Row gap={2} align="top" wrap>
                <Field label="Name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} placeholder="late night groove" />}</Field>
                <Button size="sm" onClick={keep}>Keep</Button>
              </Row>
            )}
            {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>}
            {saved.map((p) => (
              <Row key={p._id} gap={2} align="center" wrap>
                <Button size="sm" variant="quiet" onClick={() => { setCode(p.code); if (state === "playing") handlePlay(p.code); }}>{p.name}</Button>
                {p.authorHandle && <ViewerTag userHandle={p.authorHandle} />}
                <Spacer />
                {can.delete(p).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(p._id)}>Remove</Button>}
              </Row>
            ))}
          </Stack>
        </Card>

        <Text size="sm" muted>
          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.
        </Text>
      </Stack>
    </main>
  );
}

access.js

js// pattern: a saved groove. Anyone signed in keeps their own; everyone can read the gallery.
export function patternPad(doc, oldDoc, user, ctx) {
  if (!user) throw { forbidden: "sign in to keep patterns" };
  if (doc.type === "pattern" || (doc._deleted && oldDoc && oldDoc.type === "pattern")) {
    const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
    if (author !== user.userHandle) throw { forbidden: "sign your pattern with your own handle" };
    return { channels: ["patterns"], grant: { public: ["patterns"] } };
  }
  throw { forbidden: "unknown document type" };
}

Mini-notation — the whole language in a dozen symbols

A pattern string loops over one cycle; spaces divide the cycle into equal slots. Words are sounds (s("bd sd hh cp")) or notes (note("c3 e3 g3")).

write meaning
bd sd kick, then snare
~ silence for that slot
bd*2 twice in the slot
bd/2 once every two cycles
[sd sd] two squeezed into one slot
<sd cp> snare this cycle, clap the next
bd(3,8) three hits spread over eight steps (Euclidean)
bd sd, hh*8 both lines at once
bd!3 sd the same sound three slots running
hh? hh? each hit plays about half the time

Builders: stack(a, b) plays patterns together; cat(a, b) plays them one cycle after another; note("c3 e3").s("sawtooth") chooses a synth voice (sine, square, triangle, sawtooth); s("bd oh rim").bank("RolandTR808") plays those drums in that machine's voice. Effects chain on the end: .gain(0.6), .pan(0.3), .lpf(800), .hpf(200), .resonance(10), .room(0.4), .delay(0.25), .slow(2), .fast(2), .rev(), .jux(rev), .every(4, x => x.fast(2)), .sometimes(x => x.speed(2)). A signal such as sine.range(200, 2000).slow(4) turns any number into a slow sweep.

Tempo, cycles and loop length — worked examples

Tempo is in cycles: .cpm(bpm/4) for a 4/4 feel. Strudel has no bpm setting. cpm is cycles per minute, and a cycle is one pass through the pattern string, so the tempo a listener hears depends on how many beats the string holds. The engine's default is 0.5 cycles per second (.cpm(30)), which reads as 120 bpm when the string is one bar of four beats. Measured 2026-10-07 with onTrigger on the real engine: s("bd*4").cpm(120/4) hits every 0.500 s, which is 120 bpm.

The arithmetic, once: cpm = bpm ÷ beats per cycle. Decide how many beats the string spans, then divide.

the string beats it holds at 120 bpm why
s("bd*4") or s("bd sd hh cp") 4 .cpm(120/4) one bar, four quarter notes
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)
an 8-step grid read as eighth notes 4 .cpm(120/4) one bar, twice as dense
a 16-step grid of sixteenths 4 .cpm(120/4) one bar; 16 slots in 2 s
cat(a, b) where each is one bar 4 per cycle .cpm(120/4) two cycles, one bar each
note("<c2 eb2>") under a one-bar beat 4 .cpm(120/4) <> alternates across cycles; it adds no beats

Two rules fall out of the table:

  • The same grid can be two tempos. Eight steps are one bar of eighths or two bars of quarters; the app decides, names it in the UI ("8 steps = one bar"), and divides by that number. gridToCode reads any grid as one bar of four beats, so 8 steps are eighths and 16 are sixteenths; a grid meant as two bars passes beatsPerCycle = 8.
  • .slow(n) and .fast(n) change a line's length and leave the tempo alone. note("c3 eb3 g3 bb3").slow(2) under s("bd*4") spreads four notes over two bars while the drums keep their bar; .fast(2) fits the phrase into half a bar. Change .cpm only when the whole piece should speed up.

A tempo slider sets .cpm(${bpm}/4) on the outer pattern and re-evaluates, or calls repl.setCps(bpm / 60 / 4) to change the tempo of everything playing without a re-evaluate (cps is cycles per second, so bpm ÷ 60 ÷ beats per cycle). A bpm pasted straight into .cpm(120) plays four times too fast; the lane reports that shape without failing on it, since a bare literal is right when the string holds one or two beats (s("bd sd").cpm(60) is 120 bpm).

The interface writes the pattern

The pad above shows the editor shape. The other shape is the one most people want: buttons, grids and sliders that compile to a pattern string and re-evaluate it while it plays. A step grid is one function (gridToCode above); sliders are numbers dropped into the same string (toCode({ tempo, filter, space }) returning `stack(…).lpf(${filter}).room(${space}).cpm(${tempo}/8)`); a prompt box hands rewritePrompt(request, code) to callAI with a { name, code } schema and plays what comes back. Every change calls play(next) when the engine is playing, and the engine swaps patterns in time without a stop.

A groovebox whose lights follow the music, built on the same module:

App.jsx

jsximport React, { useEffect, useRef, useState } from "react";
import { useFireproof } from "use-fireproof";
import { useVibe, useViewer } from "use-vibes";
import { Badge, Button, Card, Field, Heading, Icon, IconButton, Input, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
import { Slider } from "@vibes.diy/pouf/interactive";
import { gridToCode, play, stop } from "./lib/strudel.js";

const ROWS = ["bd", "sd", "hh", "cp"];
const LABELS = { bd: "kick", sd: "snare", hh: "hat", cp: "clap" };
const START = {
  bd: [1, 0, 0, 0, 1, 0, 0, 0],
  sd: [0, 0, 1, 0, 0, 0, 1, 0],
  hh: [1, 1, 1, 1, 1, 1, 1, 1],
  cp: [0, 0, 0, 0, 0, 0, 1, 0],
};

export default function App() {
  const { database, useLiveQuery } = useFireproof("groovebox");
  const { me, can, ready } = useVibe("groovebox");
  const { requestLogin, ViewerTag } = useViewer();
  const { docs: grooves } = useLiveQuery("type", { key: "groove" });

  const [grid, setGrid] = useState(START);
  const [bpm, setBpm] = useState(110);
  const [playing, setPlaying] = useState(false);
  const [loading, setLoading] = useState(false);
  const [step, setStep] = useState(-1);
  const [name, setName] = useState("");
  const timers = useRef([]);

  useEffect(() => () => void stop(), []);

  // Every sounding event names its slot; the light moves when the sound does.
  function onEvent(hap, time, cps, target) {
    const slot = Math.round(hap.whole.begin.valueOf() * 8) % 8;
    const delayMs = Math.max(0, (target - time) * 1000);
    timers.current.push(setTimeout(() => setStep(slot), delayMs));
  }

  async function start(nextGrid = grid, nextBpm = bpm) {
    if (!playing) setLoading(true);
    const pattern = await play(gridToCode(nextGrid, ROWS, nextBpm), onEvent);
    setLoading(false);
    if (pattern) setPlaying(true);
  }

  async function halt() {
    await stop();
    for (const t of timers.current) clearTimeout(t);
    timers.current = [];
    setPlaying(false);
    setStep(-1);
  }

  function toggle(sound, i) {
    const next = { ...grid, [sound]: grid[sound].map((v, j) => (j === i ? (v ? 0 : 1) : v)) };
    setGrid(next);
    if (playing) start(next, bpm);
  }

  function tempo([value]) {
    setBpm(value);
    if (playing) start(grid, value);
  }

  const draft = { type: "groove", name: name || "untitled", grid, bpm, authorHandle: me?.userHandle };
  const verdict = can.create(draft);

  return (
    <main className="min-h-screen bg-background text-foreground p-4 md:p-8">
      <Stack gap={4}>
        <Row gap={2} align="center" wrap>
          <Heading level={1}>Groovebox</Heading>
          <Spacer />
          <Badge tone={playing ? "up" : "idle"}>{playing ? "Playing" : loading ? "Fetching the drum kit…" : "Ready"}</Badge>
        </Row>

        <Card variant="tight">
          <Stack gap={2}>
            {ROWS.map((sound) => (
              <Row key={sound} gap={2} align="center">
                <Text size="sm" muted>{LABELS[sound]}</Text>
                <Spacer />
                {grid[sound].map((on, i) => (
                  <IconButton
                    key={i}
                    size="sm"
                    label={`${LABELS[sound]} step ${i + 1}${on ? ", on" : ", off"}`}
                    variant={on ? "solid" : "quiet"}
                    tone={i === step ? "info" : on ? "up" : "idle"}
                    icon={<Icon name={on ? "idle" : "off"} size="sm" />}
                    onClick={() => toggle(sound, i)}
                  />
                ))}
              </Row>
            ))}
          </Stack>
        </Card>

        <Row gap={3} align="center" wrap>
          <Button onClick={() => (playing ? halt() : start())}>{playing ? "Stop" : "Play"}</Button>
          <Slider value={[bpm]} onChange={tempo} min={60} max={180} label="Tempo" />
          <Text size="sm" muted>{bpm} bpm</Text>
        </Row>

        <Card variant="tight">
          <Stack gap={3}>
            <Heading level={2}>Shared grooves</Heading>
            {!ready && <div aria-busy="true"><Skeleton variant="row" /></div>}
            {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>}
            {ready && me && verdict.ok && (
              <Row gap={2} align="top" wrap>
                <Field label="Groove name">{(id, d) => <Input id={id} describedBy={d} value={name} onChange={setName} />}</Field>
                <Button size="sm" onClick={() => database.put({ ...draft, createdAt: Date.now() }).then(() => setName(""))}>Publish</Button>
              </Row>
            )}
            {grooves.map((g) => (
              <Row key={g._id} gap={2} align="center" wrap>
                <Button size="sm" variant="quiet" onClick={() => { setGrid(g.grid); setBpm(g.bpm); if (playing) start(g.grid, g.bpm); }}>{g.name}</Button>
                {g.authorHandle && <ViewerTag userHandle={g.authorHandle} />}
              </Row>
            ))}
          </Stack>
        </Card>

        <Text size="sm" muted>
          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.
        </Text>
      </Stack>
    </main>
  );
}

The text-to-pattern prompt, verbatim

lib/pattern-prompt.js above is the system prompt for a text box that turns a sentence into a pattern, or an app that rewrites the pattern on request. Use it as written: it names only sounds the two sample maps hold, its four examples were played on the engine as the doc was written, and it states the tempo rule in the form the model gets right (count the beats the string holds, then divide). rewritePrompt(request, code) appends the ask and the pattern that is playing; the callAI schema is { name: string, code: string }, and the app plays code through play() so a pattern the model got wrong still surfaces through onPatternError rather than silence. For a text box with no current pattern, pass the starter example as code.

The interface follows the music

Light the step that is sounding from the engine's own events, with pattern.onTrigger(callback, false). The callback receives (hap, time, cps, targetTime): hap.whole.begin is the event's position in cycles (so begin * steps % steps is its slot), time is the audio clock now and targetTime is when the sound plays, so a setTimeout of (targetTime - time) * 1000 lands the light on the beat. The false keeps the engine's own sound output in place; the default replaces it, which is how a visualiser goes silent. Clear the pending timers in stop.

Play, stop and the lifecycle

  • Stop calls hush(), which silences every pattern the engine holds and halts the scheduler; play(nextCode) after a stop starts it again (measured: started reads false after hush(), true after the next setPattern). A useEffect cleanup that calls stop() keeps a navigated- away page quiet.
  • A change while playing calls play(next) again; the engine swaps patterns without a gap. Keep playing in state (or a ref the handlers read) so a toggle on a stopped grid changes the grid and nothing else.
  • The first play shows a short loading state with warm copy ("fetching the drum kit") and the button stays pressable; from the second play on the engine is already here.
  • A bad pattern arrives through onPatternError: show the message in the app's words beside the editor, keep the editor's text, and let the last good pattern play on. play() returns null for it, so the state stays where it was.

Persistence — the pattern text is the document

Save the pattern text itself (code, or the grid and bpm a grid compiles from) with a name, the author's handle and a timestamp; the engine rebuilds the sound from it on every play, so a saved document is tiny and remixable, and a change to the generator reaches every saved groove. Seed three or four finished patterns in seed.json so the first visit has something to press. The shared gallery is one useLiveQuery over the pattern type, and the access rule above makes it public to read and author-owned to write; a signed-out visitor jams on the pad and is invited to sign in at "Keep".

Samples and voices

Two sample maps arrive in prebake, both from jsDelivr. The Dirt-Samples kit names the drums a bare s("…") can play: bd, sd, hh, cp, cr, lt, mt, ht, perc, plus named one-offs such as 808bd, 808oh, jvbass, casio, tabla, arpy. The tidal-drum-machines map adds the banks: .bank("RolandTR808"), .bank("RolandTR909"), .bank("LinnDrum") and dozens more, each with bd sd hh oh cp rim cr lt mt ht. Measured 2026-10-07: with only the Dirt kit loaded, oh and rim play nothing and the engine logs "sound oh not found" to the console; with both maps loaded every name above plays. The engine's own synths need no download: note("c3 e3 g3").s("sawtooth"). The app's own sounds load the same way as the kits: samples({ kick: "https://…/kick.wav", snare: "…" }) inside prebake, then s("kick snare").

Mobile

Play is a large button near the top; nothing sounds until it is pressed. A grid's steps are IconButtons whose label names the row, the step and its state, and a tempo slider sits beside play rather than in a settings drawer. The loader makes and resumes the audio context inside the tap, which is what iOS asks of sound.

Shapes a Strudel vibe takes

  • A pattern pad — editor, examples, play and stop, saved patterns, a prompt box that rewrites the groove. The starter above.
  • A groovebox — a grid of pads that writes mini-notation, a tempo slider, lights that follow the music, a shared groovebook with an access rule. The second example above.
  • A knob instrument — three sliders (tempo, brightness, space) over one fixed pattern, re-evaluated on every drag.
  • A generative radio — cat of several patterns with .sometimes and .every variations and a seed, playing for as long as the tab is open.
  • A jam room — the pattern document is shared, every member's edit is a version, and the gallery doubles as the set list.

Common gotchas

  • Tempo is twice, half or four times what was meant — cpm counts cycles. Divide the bpm by the beats one cycle holds (the table above).
  • A drum name plays nothing and the console says "sound X not found" — the name is in a bank, not the bare kit. Load both sample maps and use .bank(…), or pick a bare-kit name.
  • The music stops when a visualiser is added — onTrigger with its default second argument replaces the sound output. Pass false.
  • A typo stops nothing and shows nothing — evaluate reports through onEvalError; wire it in initStrudel and show the message.
  • The engine loads twice — two loaders, or an inline import() in a handler. One memoized module-level loader.
  • A pattern built from typed text breaks on a quote — strip or escape " before interpolating a person's text into s("…").
  • Sound carries on after leaving the page — call stop() in the effect cleanup.