@brett_lamy/docstream 1.9.1 → 1.11.0

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.
Files changed (84) hide show
  1. package/README.md +54 -0
  2. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  3. package/dist/docs/DocsRenderer.js +13 -1
  4. package/dist/docs/DocsRenderer.js.map +1 -1
  5. package/dist/index.d.ts +5 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/stl/StlViewer.d.ts +13 -0
  10. package/dist/stl/StlViewer.d.ts.map +1 -0
  11. package/dist/stl/StlViewer.js +277 -0
  12. package/dist/stl/StlViewer.js.map +1 -0
  13. package/dist/stl/index.d.ts +5 -0
  14. package/dist/stl/index.d.ts.map +1 -0
  15. package/dist/stl/index.js +3 -0
  16. package/dist/stl/index.js.map +1 -0
  17. package/dist/stl/parse.d.ts +13 -0
  18. package/dist/stl/parse.d.ts.map +1 -0
  19. package/dist/stl/parse.js +69 -0
  20. package/dist/stl/parse.js.map +1 -0
  21. package/dist/strudel/StepGrid.d.ts +10 -0
  22. package/dist/strudel/StepGrid.d.ts.map +1 -0
  23. package/dist/strudel/StepGrid.js +78 -0
  24. package/dist/strudel/StepGrid.js.map +1 -0
  25. package/dist/strudel/StrudelPlayer.d.ts +21 -0
  26. package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
  27. package/dist/strudel/StrudelPlayer.js +207 -0
  28. package/dist/strudel/StrudelPlayer.js.map +1 -0
  29. package/dist/strudel/StrudelRack.d.ts +24 -0
  30. package/dist/strudel/StrudelRack.d.ts.map +1 -0
  31. package/dist/strudel/StrudelRack.js +123 -0
  32. package/dist/strudel/StrudelRack.js.map +1 -0
  33. package/dist/strudel/ast/edit.d.ts +41 -0
  34. package/dist/strudel/ast/edit.d.ts.map +1 -0
  35. package/dist/strudel/ast/edit.js +78 -0
  36. package/dist/strudel/ast/edit.js.map +1 -0
  37. package/dist/strudel/ast/index.d.ts +7 -0
  38. package/dist/strudel/ast/index.d.ts.map +1 -0
  39. package/dist/strudel/ast/index.js +4 -0
  40. package/dist/strudel/ast/index.js.map +1 -0
  41. package/dist/strudel/ast/mini.d.ts +86 -0
  42. package/dist/strudel/ast/mini.d.ts.map +1 -0
  43. package/dist/strudel/ast/mini.js +269 -0
  44. package/dist/strudel/ast/mini.js.map +1 -0
  45. package/dist/strudel/ast/program.d.ts +93 -0
  46. package/dist/strudel/ast/program.d.ts.map +1 -0
  47. package/dist/strudel/ast/program.js +288 -0
  48. package/dist/strudel/ast/program.js.map +1 -0
  49. package/dist/strudel/controls.d.ts +19 -0
  50. package/dist/strudel/controls.d.ts.map +1 -0
  51. package/dist/strudel/controls.js +109 -0
  52. package/dist/strudel/controls.js.map +1 -0
  53. package/dist/strudel/engine.d.ts +43 -0
  54. package/dist/strudel/engine.d.ts.map +1 -0
  55. package/dist/strudel/engine.js +79 -0
  56. package/dist/strudel/engine.js.map +1 -0
  57. package/dist/strudel/index.d.ts +13 -0
  58. package/dist/strudel/index.d.ts.map +1 -0
  59. package/dist/strudel/index.js +8 -0
  60. package/dist/strudel/index.js.map +1 -0
  61. package/dist/strudel/url.d.ts +5 -0
  62. package/dist/strudel/url.d.ts.map +1 -0
  63. package/dist/strudel/url.js +9 -0
  64. package/dist/strudel/url.js.map +1 -0
  65. package/dist/styles.css +564 -0
  66. package/package.json +15 -1
  67. package/src/docs/DocsRenderer.tsx +13 -1
  68. package/src/env.d.ts +3 -0
  69. package/src/index.ts +5 -0
  70. package/src/stl/StlViewer.tsx +334 -0
  71. package/src/stl/index.ts +4 -0
  72. package/src/stl/parse.ts +72 -0
  73. package/src/strudel/StepGrid.tsx +130 -0
  74. package/src/strudel/StrudelPlayer.tsx +317 -0
  75. package/src/strudel/StrudelRack.tsx +312 -0
  76. package/src/strudel/ast/edit.ts +95 -0
  77. package/src/strudel/ast/index.ts +19 -0
  78. package/src/strudel/ast/mini.ts +319 -0
  79. package/src/strudel/ast/program.ts +348 -0
  80. package/src/strudel/controls.tsx +145 -0
  81. package/src/strudel/engine.ts +112 -0
  82. package/src/strudel/index.ts +20 -0
  83. package/src/strudel/url.ts +9 -0
  84. package/src/styles.css +564 -0
@@ -0,0 +1,317 @@
1
+ import { lazy, Suspense, useEffect, useId, useRef, useState, useSyncExternalStore } from "react"
2
+ import { Code2, Loader2, Pause, Play, SlidersHorizontal, Undo2 } from "lucide-react"
3
+ import { HighlightedCode } from "../docs/HighlightedCode"
4
+ import {
5
+ DEFAULT_SAMPLES,
6
+ getStrudelSnapshot,
7
+ playStrudel,
8
+ stopStrudel,
9
+ strudelNow,
10
+ subscribeStrudel,
11
+ updateStrudel,
12
+ type StrudelHap,
13
+ type StrudelPattern,
14
+ } from "./engine"
15
+
16
+ // The rack parses the code (acorn + the mini-notation parser); it loads the first time someone opens it.
17
+ const StrudelRack = lazy(() => import("./StrudelRack"))
18
+
19
+ export interface StrudelPlayerProps {
20
+ /** Strudel pattern code (what you would type at strudel.cc). */
21
+ code?: string
22
+ /** URL of a `.strudel` file to load when `code` is absent. */
23
+ src?: string
24
+ title?: string
25
+ className?: string
26
+ /** Show the code under the player from the start. Defaults to false. */
27
+ showCode?: boolean
28
+ /** Sample banks to load before the first play. Defaults to the Tidal dirt-samples. */
29
+ samples?: readonly string[]
30
+ /** Offer the equipment rack (generated from the code) behind a toggle. Defaults to true. */
31
+ rack?: boolean
32
+ /** Open the rack from the start. */
33
+ showRack?: boolean
34
+ /** Called with the code after every rack edit. */
35
+ onCodeChange?: (code: string) => void
36
+ }
37
+
38
+ type Source = { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; code: string }
39
+
40
+ const NOTE = /^([a-g])([#bs]*)(-?\d+)?$/i
41
+ const STEPS: Record<string, number> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }
42
+
43
+ // A vertical position for an event: its pitch when it has one, else a stable lane per sample name.
44
+ function pitch(value: unknown): number | null {
45
+ if (typeof value === "number") return value
46
+ if (!value || typeof value !== "object") return null
47
+ const v = value as Record<string, unknown>
48
+ if (typeof v.note === "number") return v.note
49
+ if (typeof v.note === "string") {
50
+ const m = NOTE.exec(v.note)
51
+ if (m) {
52
+ const accidental = [...m[2]!].reduce((n, c) => n + (c === "b" ? -1 : 1), 0)
53
+ return STEPS[m[1]!.toLowerCase()]! + accidental + (Number(m[3] ?? 3) + 1) * 12
54
+ }
55
+ }
56
+ if (typeof v.freq === "number") return 69 + 12 * Math.log2(v.freq / 440)
57
+ if (typeof v.n === "number" && typeof v.s !== "string") return v.n
58
+ if (typeof v.s === "string") {
59
+ let h = 0
60
+ for (const c of v.s) h = (h * 31 + c.charCodeAt(0)) | 0
61
+ return (Math.abs(h) % 24) + (typeof v.n === "number" ? v.n : 0)
62
+ }
63
+ return null
64
+ }
65
+
66
+ const num = (t: number | { valueOf(): number }) => Number(t.valueOf())
67
+
68
+ // Scrolling piano roll: two cycles centered on the playhead, onsets only. Idle: quiet bars.
69
+ function drawRoll(canvas: HTMLCanvasElement, pattern: StrudelPattern | null, now: number | null) {
70
+ const ctx = canvas.getContext("2d")
71
+ if (!ctx) return
72
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
73
+ const w = Math.max(1, Math.round(canvas.clientWidth * dpr))
74
+ const h = Math.max(1, Math.round(canvas.clientHeight * dpr))
75
+ if (canvas.width !== w || canvas.height !== h) {
76
+ canvas.width = w
77
+ canvas.height = h
78
+ }
79
+ ctx.clearRect(0, 0, w, h)
80
+ ctx.fillStyle = getComputedStyle(canvas).color
81
+ if (!pattern || now === null) {
82
+ ctx.globalAlpha = 0.18
83
+ const bars = 48
84
+ for (let i = 0; i < bars; i++) {
85
+ const bh = (0.2 + 0.6 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6))) * h
86
+ ctx.fillRect((i / bars) * w + 1, (h - bh) / 2, w / bars - 3 * dpr, bh)
87
+ }
88
+ ctx.globalAlpha = 1
89
+ return
90
+ }
91
+ const begin = now - 1, end = now + 1
92
+ let haps: StrudelHap[]
93
+ try {
94
+ haps = pattern.queryArc(begin, end).filter((hap) => hap.whole && num(hap.whole.begin) === num(hap.part.begin))
95
+ } catch {
96
+ return
97
+ }
98
+ const pitches = haps.map((hap) => pitch(hap.value))
99
+ const known = pitches.filter((p): p is number => p !== null)
100
+ const lo = known.length ? Math.min(...known) : 0
101
+ const hi = known.length ? Math.max(...known) : 1
102
+ const range = Math.max(hi - lo, 11)
103
+ const pad = 4 * dpr
104
+ const lane = Math.max(3 * dpr, Math.min(10 * dpr, (h - 2 * pad) / (range + 1)))
105
+ const x = (t: number) => ((t - begin) / (end - begin)) * w
106
+ haps.forEach((hap, i) => {
107
+ const p = pitches[i] ?? lo
108
+ const s = num(hap.whole!.begin), e = num(hap.whole!.end)
109
+ const y = h - pad - lane - ((p - lo + (range - (hi - lo)) / 2) / range) * (h - 2 * pad - lane)
110
+ ctx.globalAlpha = s <= now && now < e ? 1 : s > now ? 0.45 : 0.25
111
+ ctx.fillRect(x(s), y, Math.max(2 * dpr, x(e) - x(s) - 1.5 * dpr), lane)
112
+ })
113
+ ctx.globalAlpha = 0.6
114
+ ctx.fillRect(w / 2 - dpr / 2, 0, dpr, h)
115
+ ctx.globalAlpha = 1
116
+ }
117
+
118
+ /** A live-coded music player: Strudel (strudel.cc) runs the pattern in this page, no iframe. */
119
+ export function StrudelPlayer({
120
+ code,
121
+ src,
122
+ title,
123
+ className,
124
+ showCode = false,
125
+ samples = DEFAULT_SAMPLES,
126
+ rack = true,
127
+ showRack = false,
128
+ onCodeChange,
129
+ }: StrudelPlayerProps) {
130
+ const id = useId()
131
+ const [source, setSource] = useState<Source>(() => (code !== undefined ? { kind: "ready", code } : { kind: "loading" }))
132
+ const [codeOpen, setCodeOpen] = useState(showCode)
133
+ const [rackOpen, setRackOpen] = useState(showRack)
134
+ // The rack edits a working copy; `playable` is that copy with the rack's mutes and solos applied.
135
+ const [edited, setEdited] = useState<string | null>(null)
136
+ const [playable, setPlayable] = useState<string | null>(null)
137
+ const [error, setError] = useState<string | null>(null)
138
+ const [cycle, setCycle] = useState(0)
139
+ const pattern = useRef<StrudelPattern | null>(null)
140
+ const canvasRef = useRef<HTMLCanvasElement>(null)
141
+ const snapshot = useSyncExternalStore(subscribeStrudel, getStrudelSnapshot, getStrudelSnapshot)
142
+ const status = snapshot.owner === id ? snapshot.status : "idle"
143
+
144
+ useEffect(() => {
145
+ if (code !== undefined) {
146
+ setSource({ kind: "ready", code })
147
+ return
148
+ }
149
+ if (!src) {
150
+ setSource({ kind: "error", message: "no pattern" })
151
+ return
152
+ }
153
+ let live = true
154
+ setSource({ kind: "loading" })
155
+ fetch(src)
156
+ .then((res) => {
157
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim())
158
+ return res.text()
159
+ })
160
+ .then((text) => live && setSource({ kind: "ready", code: text }))
161
+ .catch((err: unknown) => live && setSource({ kind: "error", message: err instanceof Error ? err.message : String(err) }))
162
+ return () => {
163
+ live = false
164
+ }
165
+ }, [code, src])
166
+
167
+ // Stop our pattern when the player unmounts or its code changes underneath it.
168
+ const ready = source.kind === "ready" ? source.code : null
169
+ useEffect(() => {
170
+ setEdited(null)
171
+ setPlayable(null)
172
+ return () => void stopStrudel(id)
173
+ }, [id, ready])
174
+ const current = edited ?? ready
175
+ const toPlay = playable ?? current
176
+
177
+ // Live edits: while playing, swap in the new pattern without stopping.
178
+ useEffect(() => {
179
+ if (status !== "playing" || !toPlay) return
180
+ const timer = setTimeout(() => {
181
+ updateStrudel(id, toPlay).then(
182
+ (p) => {
183
+ if (p) pattern.current = p
184
+ setError(null)
185
+ },
186
+ (err: unknown) => setError(err instanceof Error ? err.message : String(err)),
187
+ )
188
+ }, 120)
189
+ return () => clearTimeout(timer)
190
+ }, [toPlay])
191
+
192
+ const edit = (next: string) => {
193
+ setEdited(next)
194
+ onCodeChange?.(next)
195
+ }
196
+
197
+ useEffect(() => {
198
+ const canvas = canvasRef.current
199
+ if (!canvas) return
200
+ if (status !== "playing") {
201
+ drawRoll(canvas, null, null)
202
+ const resize = new ResizeObserver(() => drawRoll(canvas, null, null))
203
+ resize.observe(canvas)
204
+ return () => resize.disconnect()
205
+ }
206
+ let frame = 0
207
+ let shown = -1
208
+ const tick = () => {
209
+ const now = strudelNow()
210
+ drawRoll(canvas, pattern.current, now)
211
+ const whole = now === null ? 0 : Math.floor(now) + 1
212
+ if (whole !== shown) setCycle((shown = whole))
213
+ frame = requestAnimationFrame(tick)
214
+ }
215
+ tick()
216
+ return () => cancelAnimationFrame(frame)
217
+ }, [status])
218
+
219
+ const toggle = async () => {
220
+ if (!toPlay) return
221
+ if (status !== "idle") {
222
+ await stopStrudel(id)
223
+ return
224
+ }
225
+ setError(null)
226
+ try {
227
+ pattern.current = await playStrudel(id, toPlay, samples)
228
+ } catch (err) {
229
+ if (err instanceof Error && err.message === "superseded") return
230
+ setError(err instanceof Error ? err.message : String(err))
231
+ }
232
+ }
233
+
234
+ const label = title ?? (src ? src.split(/[?#]/)[0]!.split("/").pop()! : "Strudel pattern")
235
+ const codeId = `${id}-code`
236
+ const sub =
237
+ source.kind === "error"
238
+ ? `Couldn't load: ${source.message}`
239
+ : source.kind === "loading"
240
+ ? "Loading…"
241
+ : status === "loading"
242
+ ? "Loading sounds…"
243
+ : status === "playing"
244
+ ? `Cycle ${cycle}`
245
+ : "Strudel"
246
+ return (
247
+ <figure className={`docstream-strudel ${className ?? ""}`.trim()} data-state={status}>
248
+ <div className="docstream-strudel__bar">
249
+ <button
250
+ type="button"
251
+ className="docstream-strudel__play"
252
+ onClick={toggle}
253
+ disabled={source.kind !== "ready"}
254
+ aria-label={status === "idle" ? `Play ${label}` : `Stop ${label}`}
255
+ >
256
+ {status === "loading" ? (
257
+ <Loader2 className="docstream-strudel__spin" size={18} />
258
+ ) : status === "playing" ? (
259
+ <Pause size={18} />
260
+ ) : (
261
+ <Play size={18} />
262
+ )}
263
+ </button>
264
+ <div className="docstream-strudel__meta">
265
+ <span className="docstream-strudel__title">{label}</span>
266
+ <span className="docstream-strudel__sub" aria-live="polite">
267
+ {sub}
268
+ </span>
269
+ </div>
270
+ <canvas ref={canvasRef} className="docstream-strudel__roll" aria-hidden="true" />
271
+ {source.kind === "ready" && rack && (
272
+ <button
273
+ type="button"
274
+ className="docstream-strudel__toggle"
275
+ aria-expanded={rackOpen}
276
+ aria-label={rackOpen ? "Hide equipment" : "Show equipment"}
277
+ onClick={() => setRackOpen((open) => !open)}
278
+ >
279
+ <SlidersHorizontal size={16} />
280
+ </button>
281
+ )}
282
+ {source.kind === "ready" && (
283
+ <button
284
+ type="button"
285
+ className="docstream-strudel__toggle"
286
+ aria-expanded={codeOpen}
287
+ aria-controls={codeId}
288
+ aria-label={codeOpen ? "Hide code" : "Show code"}
289
+ onClick={() => setCodeOpen((open) => !open)}
290
+ >
291
+ <Code2 size={16} />
292
+ </button>
293
+ )}
294
+ </div>
295
+ {error && (
296
+ <div className="docstream-strudel__error" role="alert">
297
+ {error}
298
+ </div>
299
+ )}
300
+ {current !== null && rack && rackOpen && (
301
+ <Suspense fallback={<div className="docstream-strudel__sub">Loading equipment…</div>}>
302
+ {edited !== null && edited !== ready && (
303
+ <button type="button" className="docstream-strudel__revert" onClick={() => edit(ready!)}>
304
+ <Undo2 size={13} aria-hidden="true" /> Revert edits
305
+ </button>
306
+ )}
307
+ <StrudelRack code={current} onChange={edit} onPlayableChange={setPlayable} />
308
+ </Suspense>
309
+ )}
310
+ {current !== null && codeOpen && (
311
+ <div id={codeId} className="docstream-strudel__code">
312
+ <HighlightedCode code={current.trim()} language="javascript" />
313
+ </div>
314
+ )}
315
+ </figure>
316
+ )
317
+ }
@@ -0,0 +1,312 @@
1
+ /* The rack: equipment generated from a Strudel song's syntax tree. Every track becomes a device — mute/solo,
2
+ a gain fader, its sound and scale, a step grid for its pattern, and a knob for each effect it uses (plus any
3
+ you add) — and the song gets a master strip with the tempo. Every control edits the code in place (edit.ts),
4
+ so the code stays the source of truth: what you turn is what you'd have typed. */
5
+ import { useEffect, useMemo, useState } from "react"
6
+ import { AudioWaveform, Drum, KeyboardMusic, Plus, X } from "lucide-react"
7
+ import { miniAtoms, type MiniAtom } from "./ast/mini"
8
+ import { findCall, parseStrudel, StrudelSyntaxError, type StrudelArg, type StrudelCall, type StrudelSong, type StrudelTrack } from "./ast/program"
9
+ import { formatNumber, isAudible, mixCode, removeMethod, setMethod, setMiniAtom, setMiniSource, setTempo, type StrudelMix } from "./ast/edit"
10
+ import { Fader, Knob, defaultFormat } from "./controls"
11
+ import { StepGrid } from "./StepGrid"
12
+
13
+ export interface StrudelRackProps {
14
+ code: string
15
+ onChange: (code: string) => void
16
+ /** The code to play for the current mute/solo mix (the written code with silenced tracks). */
17
+ onPlayableChange?: (code: string) => void
18
+ className?: string
19
+ }
20
+
21
+ interface Param {
22
+ label: string
23
+ min: number
24
+ max: number
25
+ default: number
26
+ log?: boolean
27
+ format?: (v: number) => string
28
+ }
29
+
30
+ const hz = (v: number) => (v >= 1000 ? `${+(v / 1000).toFixed(1)}k` : v.toFixed(0))
31
+ const pct = (v: number) => `${Math.round(v * 100)}`
32
+
33
+ /** Effects the rack knows how to draw, in display order. Others still parse; they just get no knob. */
34
+ export const STRUDEL_PARAMS: Record<string, Param> = {
35
+ lpf: { label: "Cutoff", min: 40, max: 16000, default: 1200, log: true, format: hz },
36
+ cutoff: { label: "Cutoff", min: 40, max: 16000, default: 1200, log: true, format: hz },
37
+ lpq: { label: "Reso", min: 0, max: 30, default: 5 },
38
+ hpf: { label: "Low cut", min: 20, max: 8000, default: 200, log: true, format: hz },
39
+ attack: { label: "Attack", min: 0, max: 2, default: 0.01 },
40
+ decay: { label: "Decay", min: 0, max: 2, default: 0.2 },
41
+ sustain: { label: "Sustain", min: 0, max: 1, default: 0.6, format: pct },
42
+ release: { label: "Release", min: 0, max: 4, default: 0.3 },
43
+ vib: { label: "Vibrato", min: 0, max: 16, default: 4 },
44
+ vibmod: { label: "Vib depth", min: 0, max: 2, default: 0.2 },
45
+ shape: { label: "Shape", min: 0, max: 1, default: 0.3, format: pct },
46
+ distort: { label: "Drive", min: 0, max: 5, default: 0.5 },
47
+ crush: { label: "Crush", min: 1, max: 16, default: 4 },
48
+ coarse: { label: "Coarse", min: 1, max: 32, default: 4 },
49
+ speed: { label: "Speed", min: -2, max: 4, default: 1 },
50
+ pan: { label: "Pan", min: 0, max: 1, default: 0.5, format: (v) => (Math.abs(v - 0.5) < 0.02 ? "C" : v < 0.5 ? `L${Math.round((0.5 - v) * 200)}` : `R${Math.round((v - 0.5) * 200)}`) },
51
+ room: { label: "Reverb", min: 0, max: 1, default: 0.4, format: pct },
52
+ size: { label: "Size", min: 0, max: 8, default: 2 },
53
+ delay: { label: "Delay", min: 0, max: 1, default: 0.3, format: pct },
54
+ delaytime: { label: "Time", min: 0.01, max: 1, default: 0.25, log: true },
55
+ delayfeedback: { label: "Feedback", min: 0, max: 0.95, default: 0.5, format: pct },
56
+ }
57
+
58
+ const SYNTHS = ["sine", "triangle", "square", "sawtooth", "supersaw", "pulse", "white", "pink", "brown"]
59
+ const NOTES = ["C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B"]
60
+ const MODES = ["major", "minor", "dorian", "phrygian", "lydian", "mixolydian", "aeolian", "locrian", "major pentatonic", "minor pentatonic", "chromatic", "whole tone", "harmonic minor", "melodic minor", "blues"]
61
+
62
+ type Mini = Extract<StrudelArg, { kind: "mini" }>
63
+
64
+ const firstNumber = (call: StrudelCall | null) => (call?.args[0]?.kind === "number" ? call.args[0].value : null)
65
+ const staticMini = (call: StrudelCall | null): Mini | null => {
66
+ const a = call?.args[0]
67
+ return a?.kind === "mini" && a.mini ? a : null
68
+ }
69
+
70
+ /** A name for a track: its label, the sounds of a drum row, or its role and synth. */
71
+ export function trackName(track: StrudelTrack): string {
72
+ if (track.label) return track.label
73
+ const head = "args" in track.chain.head ? track.chain.head : null
74
+ if (track.kind === "drums") {
75
+ const mini = staticMini(findCall(track.chain, "s") ?? findCall(track.chain, "sound"))
76
+ const names = mini ? [...new Set(miniAtoms(mini.mini!, { ops: false }).filter((a) => !a.rest).map((a) => a.value.split(":")[0]!))] : []
77
+ return names.length ? names.slice(0, 4).join(" · ") : "Drums"
78
+ }
79
+ const scale = staticMini(findCall(track.chain, "scale"))
80
+ const octave = Number(/(\d)/.exec(scale?.source ?? "")?.[1] ?? NaN)
81
+ const chords = staticMini(head)?.source.includes(",")
82
+ const role = chords ? "Chords" : octave <= 2 ? "Bass" : octave >= 4 ? "Lead" : head?.name === "note" || head?.name === "n" ? "Keys" : "Synth"
83
+ return track.sound ? `${role} · ${track.sound}` : role
84
+ }
85
+
86
+ function Device({
87
+ song,
88
+ track,
89
+ mix,
90
+ onMix,
91
+ onChange,
92
+ }: {
93
+ song: StrudelSong
94
+ track: StrudelTrack
95
+ mix: StrudelMix
96
+ onMix: (kind: "mute" | "solo") => void
97
+ onChange: (code: string) => void
98
+ }) {
99
+ const { code } = song
100
+ const chain = track.chain
101
+ const head = "args" in chain.head ? chain.head : null
102
+ const pattern = staticMini(head) ?? (chain.head.name === "" && "literal" in chain.head && chain.head.literal?.kind === "mini" ? chain.head.literal : null)
103
+ const soundCall = findCall(chain, "s") ?? findCall(chain, "sound")
104
+ const sound = staticMini(soundCall)
105
+ const soundAtoms = sound ? miniAtoms(sound.mini!, { ops: false }) : []
106
+ const synth = soundAtoms.length === 1 && SYNTHS.includes(soundAtoms[0]!.value) ? soundAtoms[0]! : null
107
+ const scale = staticMini(findCall(chain, "scale"))
108
+ const scaleMatch = scale ? /^([A-Ga-g][#b]?)(\d*):(.+)$/.exec(scale.source) : null
109
+ const gainCall = findCall(chain, "gain")
110
+ const gain = firstNumber(gainCall)
111
+ const present = Object.keys(STRUDEL_PARAMS).filter((p) => findCall(chain, p))
112
+ const missing = Object.keys(STRUDEL_PARAMS).filter((p) => !present.includes(p) && p !== "cutoff")
113
+ const audible = isAudible(track, mix)
114
+ const name = trackName(track)
115
+ const Icon = track.kind === "drums" ? Drum : track.kind === "melodic" ? KeyboardMusic : AudioWaveform
116
+
117
+ const set = (method: string, value: number) => onChange(setMethod(code, chain, method, formatNumber(value)))
118
+ const setAtom = (mini: Mini) => (atom: MiniAtom, text: string) => onChange(setMiniAtom(code, mini, atom, text))
119
+
120
+ return (
121
+ <section className="strudel-device" data-kind={track.kind} data-audible={audible} aria-label={name}>
122
+ <header className="strudel-device__head">
123
+ <Icon size={15} aria-hidden="true" />
124
+ <span className="strudel-device__name" title={code.slice(track.start, track.end)}>
125
+ {name}
126
+ </span>
127
+ <button type="button" className="strudel-device__toggle" data-kind="mute" aria-pressed={mix.muted?.has(track.index) || track.muted} onClick={() => onMix("mute")} title="Mute">
128
+ M
129
+ </button>
130
+ <button type="button" className="strudel-device__toggle" data-kind="solo" aria-pressed={!!mix.solo?.has(track.index)} onClick={() => onMix("solo")} title="Solo">
131
+ S
132
+ </button>
133
+ </header>
134
+ <div className="strudel-device__body">
135
+ <Fader
136
+ label="Gain"
137
+ value={gain ?? 1}
138
+ min={0}
139
+ max={1.5}
140
+ defaultValue={1}
141
+ disabled={!!gainCall && gain === null}
142
+ {...(gainCall && gain === null ? { display: "∿", title: "Modulated by a pattern" } : {})}
143
+ format={pct}
144
+ onChange={(v) => set("gain", v)}
145
+ />
146
+ <div className="strudel-device__main">
147
+ {(synth || scaleMatch) && (
148
+ <div className="strudel-device__selects">
149
+ {synth && (
150
+ <label>
151
+ <span>Sound</span>
152
+ <select value={synth.value} onChange={(e) => onChange(setMiniAtom(code, sound!, synth, e.target.value))}>
153
+ {SYNTHS.map((s) => (
154
+ <option key={s}>{s}</option>
155
+ ))}
156
+ </select>
157
+ </label>
158
+ )}
159
+ {scaleMatch && (
160
+ <label>
161
+ <span>Key</span>
162
+ <select
163
+ value={scaleMatch[1]!.toUpperCase().replace(/^([A-G])B$/, "$1b")}
164
+ onChange={(e) => onChange(setMiniSource(code, scale!, `${e.target.value}${scaleMatch[2]}:${scaleMatch[3]}`))}
165
+ >
166
+ {NOTES.map((n) => (
167
+ <option key={n}>{n}</option>
168
+ ))}
169
+ </select>
170
+ <select value={scaleMatch[3]} onChange={(e) => onChange(setMiniSource(code, scale!, `${scaleMatch[1]}${scaleMatch[2]}:${e.target.value}`))}>
171
+ {(MODES.includes(scaleMatch[3]!) ? MODES : [scaleMatch[3]!, ...MODES]).map((m) => (
172
+ <option key={m}>{m}</option>
173
+ ))}
174
+ </select>
175
+ </label>
176
+ )}
177
+ </div>
178
+ )}
179
+ {pattern?.mini && <StepGrid mini={pattern.mini} onAtomChange={setAtom(pattern)} label={`${name} steps`} />}
180
+ {!pattern && sound?.mini && !synth && <StepGrid mini={sound.mini} onAtomChange={setAtom(sound)} label={`${name} sounds`} />}
181
+ {pattern?.error && <p className="strudel-device__error">{pattern.error.message}</p>}
182
+ <div className="strudel-device__knobs">
183
+ {present.map((p) => {
184
+ const call = findCall(chain, p)!
185
+ const value = firstNumber(call)
186
+ const spec = STRUDEL_PARAMS[p]!
187
+ return (
188
+ <div key={p} className="strudel-device__knob">
189
+ <Knob
190
+ label={spec.label}
191
+ value={value ?? spec.default}
192
+ min={spec.min}
193
+ max={spec.max}
194
+ {...(spec.log ? { log: true } : {})}
195
+ defaultValue={spec.default}
196
+ format={spec.format ?? defaultFormat}
197
+ disabled={value === null}
198
+ {...(value === null ? { display: "∿", title: `${p} is modulated: ${code.slice(call.argsRange.start, call.argsRange.end)}` } : {})}
199
+ onChange={(v) => set(p, v)}
200
+ />
201
+ <button type="button" className="strudel-device__remove" aria-label={`Remove ${spec.label}`} onClick={() => onChange(removeMethod(code, chain, p))}>
202
+ <X size={10} />
203
+ </button>
204
+ </div>
205
+ )
206
+ })}
207
+ {missing.length > 0 && (
208
+ <label className="strudel-device__add" title="Add an effect">
209
+ <Plus size={14} aria-hidden="true" />
210
+ <select
211
+ aria-label={`Add an effect to ${name}`}
212
+ value=""
213
+ onChange={(e) => e.target.value && set(e.target.value, STRUDEL_PARAMS[e.target.value]!.default)}
214
+ >
215
+ <option value="">Add…</option>
216
+ {missing.map((p) => (
217
+ <option key={p} value={p}>
218
+ {STRUDEL_PARAMS[p]!.label} ({p})
219
+ </option>
220
+ ))}
221
+ </select>
222
+ </label>
223
+ )}
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </section>
228
+ )
229
+ }
230
+
231
+ /** Equipment generated from Strudel code. Controlled: `code` in, edited code out through `onChange`. */
232
+ export function StrudelRack({ code, onChange, onPlayableChange, className }: StrudelRackProps) {
233
+ const parsed = useMemo((): { song: StrudelSong } | { error: string } => {
234
+ try {
235
+ return { song: parseStrudel(code) }
236
+ } catch (err) {
237
+ return { error: err instanceof StrudelSyntaxError ? `Syntax error: ${err.message}` : String(err) }
238
+ }
239
+ }, [code])
240
+ const [muted, setMuted] = useState<ReadonlySet<number>>(() => new Set())
241
+ const [solo, setSolo] = useState<ReadonlySet<number>>(() => new Set())
242
+ const song = "song" in parsed ? parsed.song : null
243
+ const mix = useMemo(() => ({ muted, solo }), [muted, solo])
244
+
245
+ useEffect(() => {
246
+ if (song) onPlayableChange?.(mixCode(song, mix))
247
+ }, [song, mix])
248
+
249
+ if (!song) {
250
+ return (
251
+ <div className={`strudel-rack ${className ?? ""}`.trim()}>
252
+ <p className="strudel-rack__error" role="alert">
253
+ {"error" in parsed ? parsed.error : ""}
254
+ </p>
255
+ </div>
256
+ )
257
+ }
258
+
259
+ const toggle = (set: ReadonlySet<number>, i: number) => {
260
+ const next = new Set(set)
261
+ if (!next.delete(i)) next.add(i)
262
+ return next
263
+ }
264
+ const cps = song.tempo?.cps
265
+ const master = song.masterChain
266
+ const postgain = master ? firstNumber(findCall(master, "postgain")) : null
267
+
268
+ return (
269
+ <div className={`strudel-rack ${className ?? ""}`.trim()}>
270
+ <div className="strudel-rack__master" role="group" aria-label="Master">
271
+ <span className="strudel-rack__title">Master</span>
272
+ <Knob
273
+ label="Tempo"
274
+ value={cps && Number.isFinite(cps) ? cps : 0.5}
275
+ min={0.1}
276
+ max={2}
277
+ defaultValue={0.5}
278
+ format={(v) => `${Math.round(v * 240)} bpm`}
279
+ onChange={(v) => onChange(setTempo(song, v))}
280
+ />
281
+ {master && (
282
+ <Knob
283
+ label="Output"
284
+ value={postgain ?? 1}
285
+ min={0}
286
+ max={2}
287
+ defaultValue={1}
288
+ format={pct}
289
+ onChange={(v) => onChange(setMethod(code, master, "postgain", formatNumber(v)))}
290
+ />
291
+ )}
292
+ <span className="strudel-rack__summary">
293
+ {song.tracks.length} track{song.tracks.length === 1 ? "" : "s"}
294
+ {song.layout === "stack" ? " · stack" : song.layout === "labels" ? " · $:" : ""}
295
+ </span>
296
+ </div>
297
+ {song.tracks.length === 0 && <p className="strudel-rack__error">No pattern found.</p>}
298
+ {song.tracks.map((t) => (
299
+ <Device
300
+ key={t.index}
301
+ song={song}
302
+ track={t}
303
+ mix={mix}
304
+ onChange={onChange}
305
+ onMix={(kind) => (kind === "mute" ? setMuted(toggle(muted, t.index)) : setSolo(toggle(solo, t.index)))}
306
+ />
307
+ ))}
308
+ </div>
309
+ )
310
+ }
311
+
312
+ export default StrudelRack