anvilsign in

collin/strudel-claude

pre-demo / README.md

RenderedSource

Strudel × Claude

Live-code music by chatting. A Strudel IDE on the left, a Claude chat panel on the right. Describe the music you want and Claude writes a runnable Strudel pattern into the editor and plays it.

┌────────────────────────────┬──────────────────────┐
│  🌀 Strudel IDE            │  ✦ Claude chat       │
│  (CodeMirror editor)       │  "dark techno with   │
│  ▶ Play / ■ Stop (toggle)  │   a rolling bass"    │
│  stack( s("bd*4") ... )    │  → writes + plays it │
└────────────────────────────┴──────────────────────┘

Architecture

  • Frontend (Vite): CodeMirror editor + @strudel/web audio engine + chat UI. Sounds come from Strudel's standard prebake: 71 drum machines, the GM soundfont set, VCSL instruments, and a handful of character samples.
  • Backend (Express): holds your Anthropic key, calls the Claude Messages API (claude-opus-4-8) with the system prompt from PROMPT.md and structured output ({ message, code }). The browser never sees the key.
browser ──/api/generate──▶ Express ──▶ Claude API
        ◀── { message, code } ──

Setup

Credentials — any of these works:

  • ant auth login (recommended), or
  • cp .env.example .env and set ANTHROPIC_API_KEY, or
  • nothing at all: click 🔑 Add API key at the bottom of the sidebar and paste your own. It is kept in that browser's localStorage and sent to the backend in an x-anthropic-key header, which uses it for that request only — never logged, never stored server-side. A personal key takes precedence over the server's, so several people can share one instance with their own billing.
npm install
npm run dev      # starts Vite (5173) + backend (8787) together

Open http://localhost:5173, press ▶ Play for the starter pattern, or ask Claude on the right (Cmd/Ctrl+Enter to send).

Driving it from a terminal

The app broadcasts what it is playing — the code, the timing of every event in it, and the chat transcript — over a WebSocket at /ws. A terminal client draws all three and can send prompts back:

npm run tui                                   # from a checkout
curl -s http://localhost:8787/tui.py | python3 - localhost:8787   # from anywhere

tui/strudel-tui.py is one file, standard library only — no pip install, and the server hands it out at /tui.py as text/plain, so you can read it in a browser or curl -sO it before running it. It takes the app's address (localhost:8787, https://you.ngrok.app, ws://…/ws — all understood) and otherwise defaults to localhost:8787.

 ● playing                                cycle   2.000   0.500 cps   120 bpm
 ────────────────────────────────────────────────────────────────────────────
  1 stack(                                 │› you
  2   s("bd*4").gain(0.9),                 │  give me a dark rolling techno
  3   s("~ cp").room(0.3),                 │
  4   note("<c2 g1>").s("sawtooth")        │✦ claude
  5 )                                      │  Rolling kick on every beat,
    ↑ the characters that made each event  │  offbeat hats, a low saw bass.
      light up as it sounds and fade out   │
      across its length                    │✦ claude is thinking…
 ╷·····┃···············╷······················╷·············  ← one cycle, ┃ = now
 › make it half time and add a clap on 3

Panes sit side by side when the terminal is at least 100 columns wide and stack when it isn't. Keys:

qquit
i or Enterwrite a prompt (Enter sends, Esc cancels, ^U/^W edit)
j / kscroll the code
J / Kscroll the chat
ffollow the sounding code (on by default)

A prompt typed here goes through the same path as the composer in the page, so the turn appears in both, reverts in both, and is saved to the session like any other. If a turn is already in flight the new prompt is refused rather than queued — two user messages in a row is an API error — and the terminal says so.

--light for a light-background terminal. It needs 24-bit colour, which every current terminal has.

How the timing gets there

Each message carries a window of upcoming events plus the playhead position and tempo, twice a second — not a frame per redraw. The viewer runs its own clock and animates between messages, so it stays smooth over a slow link and survives the browser tab throttling its timers to 1 Hz in the background. That last part matters: Strudel's own highlighting is requestAnimationFrame-driven and stops dead in a hidden tab, which is exactly when you are looking at the terminal instead — so src/broadcast.js polls the scheduler rather than hanging off the draw loop.

The hub takes one producer (the browser tab that owns the audio) and any number of viewers. A viewer may send exactly one kind of message — a prompt — and the hub drops everything else it sends, so a terminal cannot rewrite the editor, work the transport, or push anything onto another viewer's screen.

Open the app in two tabs and the first one keeps the slot; the second is told to stand by and stays silent until the first goes away, at which point it is promoted and re-announces itself. The terminal shows (+1 idle tab) so it is clear why it isn't following the tab you happen to be looking at. The slot only changes hands when it is free — an earlier version gave it to whoever connected last, and since a displaced tab reconnects half a second later, two open tabs took turns evicting each other and the terminal flipped between them twice a second, forever.

It is unauthenticated, though. If you expose the port, anyone who can reach it can read your session and spend your API credit on prompts, which is a bigger deal than it was when viewers could only watch. Keep it on localhost, or put something in front of it, unless you mean to share the console.

How it works

  • src/strudel.js — wraps initStrudel() / evaluate() / hush().
  • src/main.js — editor, transport, and chat; drops Claude's code into the editor and auto-plays it.
  • src/sessions.js — localStorage-backed sessions (code + chat per session).
  • Revert: every user message stores the code as it was before that turn, so hovering Claude's reply and clicking ↩ undoes that turn — rewinding both the chat and the pattern. The status bar then offers a one-level Undo.
  • src/apikey.js — the bring-your-own-key control; GET /api/config tells it whether the server has a key of its own.
  • server/index.js — the Claude call, history trimming and prompt caching.
  • server/broadcast.js — the /ws timing hub; src/broadcast.js feeds it and tui/strudel-tui.py consumes it.
  • PROMPT.md — the entire system prompt, read from disk at boot. Edit it to teach Claude new sounds or change house style; no code change needed, and the prompt diffs like any other file. Restart the API to pick up edits.

Keeping the prompt honest

PROMPT.md tells Claude not to invent sound names, because an unknown name in Strudel plays silence rather than raising — a wrong name is a part the user never hears. So section 3 of the prompt has to match what src/strudel.js actually prebakes. If you add or remove a sample pack, update that section.

Ideas to extend

  • Stream Claude's reply token-by-token into the chat bubble.
  • Add a "remix current selection" action.
  • Swap the plain editor for @strudel/codemirror to get Strudel-native highlighting and event flashing.
  • Persist sessions / share patterns by URL.