Architecture
How the pieces fit together. The short version: one audio scheduler shared globally, state in Zustand, AI behind a rate-limited API, every code change is a proposal until the user confirms.
Components
Editor
CodeMirror 6 · @strudel/codemirror (StrudelMirror)Where code is written and evaluated
Autocomplete, hover docs, multi-cursor, vim/vscode/emacs keybindings. onChange is wired to the Zustand store so code stays in sync with persistence.
Audio scheduler
@strudel/core Cyclist · Web Audio APISingle global clock, shared across the app
The scheduler is module-level, not per-component. Navigating /radio → /studio doesn't stop audio — the pattern keeps playing until something replaces it. That's what enables 'live from radio' sync.
Zustand store
zustand with persist middlewareState for the whole studio session
Persists: mode, code, trackTitle, editor settings, DJ deck. Not persisted: playing flag, broadcast state (radio). localStorage key: pc.store.
AI backend
Vercel AI SDK · @ai-sdk/anthropic · @ai-sdk/groqCompose, evolve, chat
Routes at /api/compose, /api/evolve, /api/chat, /api/patterns. Server-side keys. Rate limited per IP. Model router picks Haiku for small changes and Sonnet for composition.
Chat diff flow
Custom LCS line diff · CodeDiff componentAI changes are proposals, not commits
AI returns JSON with { code, message }. Chat extracts the code and renders it as a diff. Listen temporarily swaps via editor.setCode + evaluate, saving a snapshot. Keep finalizes, Reject restores the snapshot.
DJ deck
buildDeckComposePrompt (lib/ai/deck-prompt.ts)Structured prompt builder
Turns UI state (genre + key + BPM + energy/space/brightness sliders) into a deterministic prompt string that gets passed to /api/compose.
Radio
Self-contained React page · shares the global schedulerA page that composes and broadcasts live
Composes on Play, evolves every 30s, writes broadcastCode/broadcastTitle/broadcastActive into the store. Studio reads these on mount to auto-tune to the live pattern. First edit forks.
Supabase
@supabase/ssr · Postgres with RLSAuth + pattern persistence
Graceful fallback when env vars are missing — the app still works with localStorage-only state. Auth via email / Google OAuth. Patterns are private by default; toggle Public for /p/[id] links.
A typical flow
- 1
User hits Play
Transport bar calls editor.toggle(). First-time: AudioContext resumes (user gesture), prebake promise awaits sample loading + evalScope registration.
- 2
Code evaluates
Strudel's transpiler rewrites the code, evalScope executes it, the resulting pattern is installed in the global scheduler. Hap highlighting kicks in on played notes.
- 3
User types a chat message
ChatPanel POSTs to /api/chat with { mode, currentCode }. Model router picks a model. Response streams back as JSON with { message, code }.
- 4
Diff renders
CodeDiff computes line-level LCS between current code and AI's code, renders +/-/context with line numbers. Three buttons: Listen / Keep / Reject.
- 5
User clicks Listen
Studio saves a snapshot ref, calls editor.setCode(proposed), editor.evaluate(). The scheduler swaps patterns at the next cycle boundary — no audio glitch.
- 6
User clicks Keep or Reject
Keep: snapshot cleared, proposed code stays. Reject: editor.setCode(snapshot), editor.evaluate(), scheduler restores original.
Design principles
- State survives navigationZustand + persist means refreshing the page keeps the user where they are, with their code intact. No redirects to landing.
- One scheduler, many viewsThe audio clock is shared. /radio and /studio aren't separate audio apps — they're two views onto the same pattern engine. That's what makes radio → studio feel seamless.
- AI is a collaborator, not a narratorEvery code change from the AI is a diff the user can Listen to before committing. No surprises, no irreversible rewrites.
- Fallbacks over failuresSupabase env missing? Fall back to localStorage. AI rate-limited? Return a graceful message. Audio init fails? Editor still works.