anvilsign in

collin/strudel-claude

main / README.md

RenderedSource

1# Strudel × Claude
2
3Live-code music by chatting. A [Strudel](https://strudel.cc) IDE on the left,
4a Claude chat panel on the right. Describe the music you want and Claude writes
5a runnable Strudel pattern into the editor and plays it.
6
7```
8┌────────────────────────────┬──────────────────────┐
9│ 🌀 Strudel IDE │ ✦ Claude chat │
10│ (CodeMirror editor) │ "dark techno with │
11│ ▶ Play / ■ Stop (toggle) │ a rolling bass" │
12│ stack( s("bd*4") ... ) │ → writes + plays it │
13└────────────────────────────┴──────────────────────┘
14```
15
16## Architecture
17
18- **Frontend** (Vite): CodeMirror editor + `@strudel/web` audio engine + chat UI.
19 Sounds come from Strudel's standard prebake: 71 drum machines, the GM soundfont
20 set, VCSL instruments, and a handful of character samples.
21- **Backend** (Express): holds your Anthropic key, calls the Claude Messages API
22 (`claude-opus-4-8`) with the system prompt from `PROMPT.md` and structured
23 output (`{ message, code }`). The browser never sees the key.
24
25```
26browser ──/api/generate──▶ Express ──▶ Claude API
27 ◀── { message, code } ──
28```
29
30## Setup
31
32Credentials — any of these works:
33
34- `ant auth login` (recommended), or
35- `cp .env.example .env` and set `ANTHROPIC_API_KEY`, or
36- nothing at all: click **🔑 Add API key** at the bottom of the sidebar and paste
37 your own. It is kept in that browser's localStorage and sent to the backend in
38 an `x-anthropic-key` header, which uses it for that request only — never
39 logged, never stored server-side. A personal key takes precedence over the
40 server's, so several people can share one instance with their own billing.
41
42```bash
43npm install
44npm run dev # starts Vite (5173) + backend (8787) together
45```
46
47Open http://localhost:5173, press **▶ Play** for the starter pattern, or ask
48Claude on the right (Cmd/Ctrl+Enter to send).
49
50## Driving it from a terminal
51
52The app broadcasts what it is playing — the code, the timing of every event in
53it, and the chat transcript — over a WebSocket at `/ws`. A terminal client
54draws all three and can send prompts back:
55
56```bash
57npm run tui # from a checkout
58curl -s http://localhost:8787/tui.py | python3 - localhost:8787 # from anywhere
59```
60
61`tui/strudel-tui.py` is one file, standard library only — no pip install, and
62the server hands it out at `/tui.py` as `text/plain`, so you can read it in a
63browser or `curl -sO` it before running it. It takes the app's address
64(`localhost:8787`, `https://you.ngrok.app`, `ws://…/ws` — all understood) and
65otherwise defaults to `localhost:8787`.
66
67```
68 ● playing cycle 2.000 0.500 cps 120 bpm
69 ────────────────────────────────────────────────────────────────────────────
70 1 stack( │› you
71 2 s("bd*4").gain(0.9), │ give me a dark rolling techno
72 3 s("~ cp").room(0.3), │
73 4 note("<c2 g1>").s("sawtooth") │✦ claude
74 5 ) │ Rolling kick on every beat,
75 ↑ the characters that made each event │ offbeat hats, a low saw bass.
76 light up as it sounds and fade out │
77 across its length │✦ claude is thinking…
78 ╷·····┃···············╷······················╷············· ← one cycle, ┃ = now
79 › make it half time and add a clap on 3
80```
81
82Panes sit side by side when the terminal is at least 100 columns wide and stack
83when it isn't. Keys:
84
85| | |
86|---|---|
87| `q` | quit |
88| `i` or `Enter` | write a prompt (`Enter` sends, `Esc` cancels, `^U`/`^W` edit) |
89| `j` / `k` | scroll the code |
90| `J` / `K` | scroll the chat |
91| `f` | follow the sounding code (on by default) |
92
93A prompt typed here goes through the same path as the composer in the page, so
94the turn appears in both, reverts in both, and is saved to the session like any
95other. If a turn is already in flight the new prompt is refused rather than
96queued — two user messages in a row is an API error — and the terminal says so.
97
98`--light` for a light-background terminal. It needs 24-bit colour, which every
99current terminal has.
100
101### How the timing gets there
102
103Each message carries a *window* of upcoming events plus the playhead position
104and tempo, twice a second — not a frame per redraw. The viewer runs its own
105clock and animates between messages, so it stays smooth over a slow link and
106survives the browser tab throttling its timers to 1 Hz in the background. That
107last part matters: Strudel's own highlighting is `requestAnimationFrame`-driven
108and stops dead in a hidden tab, which is exactly when you are looking at the
109terminal instead — so `src/broadcast.js` polls the scheduler rather than
110hanging off the draw loop.
111
112The hub takes one producer (the browser tab that owns the audio) and any number
113of viewers. A viewer may send exactly one kind of message — a prompt — and the
114hub drops everything else it sends, so a terminal cannot rewrite the editor,
115work the transport, or push anything onto another viewer's screen.
116
117Open the app in two tabs and the first one keeps the slot; the second is told
118to stand by and stays silent until the first goes away, at which point it is
119promoted and re-announces itself. The terminal shows `(+1 idle tab)` so it is
120clear why it isn't following the tab you happen to be looking at. The slot only
121changes hands when it is free — an earlier version gave it to whoever connected
122last, and since a displaced tab reconnects half a second later, two open tabs
123took turns evicting each other and the terminal flipped between them twice a
124second, forever.
125
126It is unauthenticated, though. If you expose the port, anyone who can reach it
127can read your session *and spend your API credit on prompts*, which is a bigger
128deal than it was when viewers could only watch. Keep it on localhost, or put
129something in front of it, unless you mean to share the console.
130
131## How it works
132
133- `src/strudel.js` — wraps `initStrudel()` / `evaluate()` / `hush()`.
134- `src/main.js` — editor, transport, and chat; drops Claude's code into the
135 editor and auto-plays it.
136- `src/sessions.js` — localStorage-backed sessions (code + chat per session).
137- Revert: every user message stores the code as it was *before* that turn, so
138 hovering Claude's reply and clicking ↩ undoes that turn — rewinding both the
139 chat and the pattern. The status bar then offers a one-level **Undo**.
140- `src/apikey.js` — the bring-your-own-key control; `GET /api/config` tells it
141 whether the server has a key of its own.
142- `server/index.js` — the Claude call, history trimming and prompt caching.
143- `server/broadcast.js` — the `/ws` timing hub; `src/broadcast.js` feeds it and
144 `tui/strudel-tui.py` consumes it.
145- `PROMPT.md` — **the entire system prompt**, read from disk at boot. Edit it to
146 teach Claude new sounds or change house style; no code change needed, and the
147 prompt diffs like any other file. Restart the API to pick up edits.
148
149### Keeping the prompt honest
150
151`PROMPT.md` tells Claude not to invent sound names, because an unknown name in
152Strudel plays *silence* rather than raising — a wrong name is a part the user
153never hears. So section 3 of the prompt has to match what `src/strudel.js`
154actually prebakes. If you add or remove a sample pack, update that section.
155
156## Ideas to extend
157
158- Stream Claude's reply token-by-token into the chat bubble.
159- Add a "remix current selection" action.
160- Swap the plain editor for `@strudel/codemirror` to get Strudel-native
161 highlighting and event flashing.
162- Persist sessions / share patterns by URL.