@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.
- package/README.md +54 -0
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +13 -1
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/stl/StlViewer.d.ts +13 -0
- package/dist/stl/StlViewer.d.ts.map +1 -0
- package/dist/stl/StlViewer.js +277 -0
- package/dist/stl/StlViewer.js.map +1 -0
- package/dist/stl/index.d.ts +5 -0
- package/dist/stl/index.d.ts.map +1 -0
- package/dist/stl/index.js +3 -0
- package/dist/stl/index.js.map +1 -0
- package/dist/stl/parse.d.ts +13 -0
- package/dist/stl/parse.d.ts.map +1 -0
- package/dist/stl/parse.js +69 -0
- package/dist/stl/parse.js.map +1 -0
- package/dist/strudel/StepGrid.d.ts +10 -0
- package/dist/strudel/StepGrid.d.ts.map +1 -0
- package/dist/strudel/StepGrid.js +78 -0
- package/dist/strudel/StepGrid.js.map +1 -0
- package/dist/strudel/StrudelPlayer.d.ts +21 -0
- package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
- package/dist/strudel/StrudelPlayer.js +207 -0
- package/dist/strudel/StrudelPlayer.js.map +1 -0
- package/dist/strudel/StrudelRack.d.ts +24 -0
- package/dist/strudel/StrudelRack.d.ts.map +1 -0
- package/dist/strudel/StrudelRack.js +123 -0
- package/dist/strudel/StrudelRack.js.map +1 -0
- package/dist/strudel/ast/edit.d.ts +41 -0
- package/dist/strudel/ast/edit.d.ts.map +1 -0
- package/dist/strudel/ast/edit.js +78 -0
- package/dist/strudel/ast/edit.js.map +1 -0
- package/dist/strudel/ast/index.d.ts +7 -0
- package/dist/strudel/ast/index.d.ts.map +1 -0
- package/dist/strudel/ast/index.js +4 -0
- package/dist/strudel/ast/index.js.map +1 -0
- package/dist/strudel/ast/mini.d.ts +86 -0
- package/dist/strudel/ast/mini.d.ts.map +1 -0
- package/dist/strudel/ast/mini.js +269 -0
- package/dist/strudel/ast/mini.js.map +1 -0
- package/dist/strudel/ast/program.d.ts +93 -0
- package/dist/strudel/ast/program.d.ts.map +1 -0
- package/dist/strudel/ast/program.js +288 -0
- package/dist/strudel/ast/program.js.map +1 -0
- package/dist/strudel/controls.d.ts +19 -0
- package/dist/strudel/controls.d.ts.map +1 -0
- package/dist/strudel/controls.js +109 -0
- package/dist/strudel/controls.js.map +1 -0
- package/dist/strudel/engine.d.ts +43 -0
- package/dist/strudel/engine.d.ts.map +1 -0
- package/dist/strudel/engine.js +79 -0
- package/dist/strudel/engine.js.map +1 -0
- package/dist/strudel/index.d.ts +13 -0
- package/dist/strudel/index.d.ts.map +1 -0
- package/dist/strudel/index.js +8 -0
- package/dist/strudel/index.js.map +1 -0
- package/dist/strudel/url.d.ts +5 -0
- package/dist/strudel/url.d.ts.map +1 -0
- package/dist/strudel/url.js +9 -0
- package/dist/strudel/url.js.map +1 -0
- package/dist/styles.css +564 -0
- package/package.json +15 -1
- package/src/docs/DocsRenderer.tsx +13 -1
- package/src/env.d.ts +3 -0
- package/src/index.ts +5 -0
- package/src/stl/StlViewer.tsx +334 -0
- package/src/stl/index.ts +4 -0
- package/src/stl/parse.ts +72 -0
- package/src/strudel/StepGrid.tsx +130 -0
- package/src/strudel/StrudelPlayer.tsx +317 -0
- package/src/strudel/StrudelRack.tsx +312 -0
- package/src/strudel/ast/edit.ts +95 -0
- package/src/strudel/ast/index.ts +19 -0
- package/src/strudel/ast/mini.ts +319 -0
- package/src/strudel/ast/program.ts +348 -0
- package/src/strudel/controls.tsx +145 -0
- package/src/strudel/engine.ts +112 -0
- package/src/strudel/index.ts +20 -0
- package/src/strudel/url.ts +9 -0
- 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
|